From bb1a09b40f9a65893c219e04133cb3f119e291d3 Mon Sep 17 00:00:00 2001 From: Gael Leblan Date: Wed, 26 Aug 2026 16:22:22 +0200 Subject: [PATCH 1/2] feat(audit-log): add an annotation to hash or redact a property to audit log (#0000) --- .../aop/audit_log/AuditObjectMapper.java | 77 ++++++++++++++++- .../io/openaev/api/users/dto/UserInput.java | 20 +++-- .../io/openaev/api/users/dto/UserOutput.java | 15 ++-- .../user/form/me/UpdateMePasswordInput.java | 3 + .../rest/user/form/me/UpdateProfileInput.java | 6 ++ .../rest/user/form/player/PlayerInput.java | 9 ++ .../rest/user/form/player/PlayerOutput.java | 9 ++ .../user/form/user/ChangePasswordInput.java | 3 + .../user/form/user/UpdatePasswordInput.java | 2 + .../user/form/user/UpdateUserInfoInput.java | 5 ++ .../utils/object/ObjectRedactionUtils.java | 83 ++++++++++++++++--- .../aop/audit_log/AuditObjectMapperTest.java | 48 +++++++++++ .../io/openaev/service/LogServiceTest.java | 2 +- .../openaev/database/audit/AuditLogHash.java | 11 +++ .../database/audit/AuditLogRedact.java | 11 +++ .../java/io/openaev/database/model/Agent.java | 2 +- .../java/io/openaev/database/model/Asset.java | 3 + .../database/model/PhishingResult.java | 2 + .../model/TenantXtmHubRegistration.java | 2 + .../java/io/openaev/database/model/User.java | 14 ++++ 20 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 openaev-model/src/main/java/io/openaev/database/audit/AuditLogHash.java create mode 100644 openaev-model/src/main/java/io/openaev/database/audit/AuditLogRedact.java diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java index f2a34e319c1..215805eda8a 100644 --- a/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java @@ -1,14 +1,27 @@ package io.openaev.aop.audit_log; +import static io.openaev.helper.CryptoHelper.hashWithSHA256; + import com.fasterxml.jackson.databind.AnnotationIntrospector; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.introspect.AnnotatedMember; import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.type.TypeFactory; +import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.AuditLogIgnore; +import io.openaev.database.audit.AuditLogRedact; +import java.io.IOException; +import java.util.List; import org.springframework.stereotype.Component; -/** Dedicated mapper for audit payloads that can ignore {@link AuditLogIgnore} fields. */ +/** Dedicated mapper for audit payloads that can ignore/mask annotated fields. */ @Component public class AuditObjectMapper { @@ -16,9 +29,12 @@ public class AuditObjectMapper { public AuditObjectMapper(ObjectMapper source) { ObjectMapper copy = source.copy(); + copy.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); AnnotationIntrospector base = copy.getSerializationConfig().getAnnotationIntrospector(); copy.setAnnotationIntrospector( AnnotationIntrospector.pair(new AuditLogIgnoreIntrospector(), base)); + copy.setSerializerFactory( + copy.getSerializerFactory().withSerializerModifier(new AuditMaskingSerializerModifier())); this.mapper = copy; } @@ -32,4 +48,63 @@ public boolean hasIgnoreMarker(AnnotatedMember member) { return member.hasAnnotation(AuditLogIgnore.class) || super.hasIgnoreMarker(member); } } + + private static class AuditMaskingSerializerModifier extends BeanSerializerModifier { + + @Override + public List changeProperties( + SerializationConfig config, + com.fasterxml.jackson.databind.BeanDescription beanDesc, + List beanProperties) { + for (BeanPropertyWriter writer : beanProperties) { + AnnotatedMember member = writer.getMember(); + if (member == null) { + continue; + } + if (member.hasAnnotation(AuditLogHash.class)) { + writer.assignSerializer(AuditHashSerializer.INSTANCE); + } else if (member.hasAnnotation(AuditLogRedact.class)) { + writer.assignSerializer(AuditRedactSerializer.INSTANCE); + } + } + return beanProperties; + } + } + + private static class AuditHashSerializer extends StdSerializer { + + private static final AuditHashSerializer INSTANCE = new AuditHashSerializer(); + + private AuditHashSerializer() { + super(TypeFactory.defaultInstance().constructType(Object.class)); + } + + @Override + public void serialize( + Object value, com.fasterxml.jackson.core.JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value == null) { + provider.defaultSerializeNull(gen); + return; + } + gen.writeString(hashWithSHA256(String.valueOf(value))); + } + } + + private static class AuditRedactSerializer extends StdSerializer { + + private static final AuditRedactSerializer INSTANCE = new AuditRedactSerializer(); + private static final String REDACTED = "[REDACTED]"; + + private AuditRedactSerializer() { + super(TypeFactory.defaultInstance().constructType(Object.class)); + } + + @Override + public void serialize( + Object value, com.fasterxml.jackson.core.JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeString(REDACTED); + } + } } diff --git a/openaev-api/src/main/java/io/openaev/api/users/dto/UserInput.java b/openaev-api/src/main/java/io/openaev/api/users/dto/UserInput.java index dba7707aa16..c9b4315f820 100644 --- a/openaev-api/src/main/java/io/openaev/api/users/dto/UserInput.java +++ b/openaev-api/src/main/java/io/openaev/api/users/dto/UserInput.java @@ -16,19 +16,27 @@ import static io.openaev.config.AppConfig.PHONE_REGEXP; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogIgnore; +import io.openaev.database.audit.AuditLogRedact; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import java.util.List; public record UserInput( - @JsonProperty(ALIAS_EMAIL) @NotBlank @Email(message = EMAIL_FORMAT) String email, - @JsonProperty(ALIAS_FIRSTNAME) String firstname, - @JsonProperty(ALIAS_LASTNAME) String lastname, - @JsonProperty(ALIAS_PLAIN_PASSWORD) String plainPassword, + @AuditLogIgnore @JsonProperty(ALIAS_EMAIL) @NotBlank @Email(message = EMAIL_FORMAT) + String email, + @AuditLogIgnore @JsonProperty(ALIAS_FIRSTNAME) String firstname, + @AuditLogIgnore @JsonProperty(ALIAS_LASTNAME) String lastname, + @AuditLogRedact @JsonProperty(ALIAS_PLAIN_PASSWORD) String plainPassword, @JsonProperty(ALIAS_PGP_KEY) String pgpKey, - @JsonProperty(ALIAS_PHONE) @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) String phone, - @JsonProperty(ALIAS_PHONE2) @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) + @AuditLogIgnore + @JsonProperty(ALIAS_PHONE) + @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) + String phone, + @AuditLogIgnore + @JsonProperty(ALIAS_PHONE2) + @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) String phone2, @JsonProperty(ALIAS_ORGANIZATION) String organizationId, @JsonProperty(ALIAS_TAGS) List tagIds, diff --git a/openaev-api/src/main/java/io/openaev/api/users/dto/UserOutput.java b/openaev-api/src/main/java/io/openaev/api/users/dto/UserOutput.java index 8980e3574ef..8cc4f3c21da 100644 --- a/openaev-api/src/main/java/io/openaev/api/users/dto/UserOutput.java +++ b/openaev-api/src/main/java/io/openaev/api/users/dto/UserOutput.java @@ -3,6 +3,8 @@ import static io.openaev.config.AppConfig.EMAIL_FORMAT; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogHash; +import io.openaev.database.audit.AuditLogIgnore; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import java.util.List; @@ -10,12 +12,13 @@ public record UserOutput( @JsonProperty(ALIAS_ID) @NotBlank String id, - @JsonProperty(ALIAS_EMAIL) @NotBlank @Email(message = EMAIL_FORMAT) String email, - @JsonProperty(ALIAS_FIRSTNAME) String firstname, - @JsonProperty(ALIAS_LASTNAME) String lastname, - @JsonProperty(ALIAS_PGP_KEY) String pgpKey, - @JsonProperty(ALIAS_PHONE) String phone, - @JsonProperty(ALIAS_PHONE2) String phone2, + @AuditLogIgnore @JsonProperty(ALIAS_EMAIL) @NotBlank @Email(message = EMAIL_FORMAT) + String email, + @AuditLogIgnore @JsonProperty(ALIAS_FIRSTNAME) String firstname, + @AuditLogIgnore @JsonProperty(ALIAS_LASTNAME) String lastname, + @AuditLogHash @JsonProperty(ALIAS_PGP_KEY) String pgpKey, + @AuditLogIgnore @JsonProperty(ALIAS_PHONE) String phone, + @AuditLogIgnore @JsonProperty(ALIAS_PHONE2) String phone2, @JsonProperty(ALIAS_ORGANIZATION_ID) String organizationId, @JsonProperty(ALIAS_ORGANIZATION_NAME) String organizationName, @JsonProperty(ALIAS_TAGS) Set tags, diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateMePasswordInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateMePasswordInput.java index 4fb5e75af3f..48d1d7848bc 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateMePasswordInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateMePasswordInput.java @@ -3,16 +3,19 @@ import static io.openaev.config.AppConfig.MANDATORY_MESSAGE; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogRedact; import jakarta.validation.constraints.NotBlank; public class UpdateMePasswordInput { @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_current_password") + @AuditLogRedact private String currentPassword; @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_plain_password") + @AuditLogRedact private String password; public String getCurrentPassword() { diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateProfileInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateProfileInput.java index 52c3c1e4931..50fb13b9f92 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateProfileInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/me/UpdateProfileInput.java @@ -4,6 +4,7 @@ import static io.openaev.config.AppConfig.MANDATORY_MESSAGE; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogIgnore; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.Getter; @@ -16,14 +17,17 @@ public class UpdateProfileInput { @Email(message = EMAIL_FORMAT) @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_email") + @AuditLogIgnore private String email; @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_firstname") + @AuditLogIgnore private String firstname; @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_lastname") + @AuditLogIgnore private String lastname; @JsonProperty("user_organization") @@ -31,6 +35,7 @@ public class UpdateProfileInput { @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_lang") + @AuditLogIgnore private String lang; @NotBlank(message = MANDATORY_MESSAGE) @@ -38,6 +43,7 @@ public class UpdateProfileInput { private String theme; @JsonProperty("user_country") + @AuditLogIgnore private String country; @JsonProperty("user_home_dashboard") diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerInput.java index 2a2f2f7907d..23624a9e5de 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerInput.java @@ -5,6 +5,8 @@ import static io.openaev.config.AppConfig.PHONE_REGEXP; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogHash; +import io.openaev.database.audit.AuditLogIgnore; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -20,18 +22,22 @@ public class PlayerInput { @Email(message = EMAIL_FORMAT) @NotBlank @JsonProperty("user_email") + @AuditLogIgnore private String email; @JsonProperty("user_firstname") + @AuditLogIgnore private String firstname; @JsonProperty("user_lastname") + @AuditLogIgnore private String lastname; @JsonProperty("user_organization") private String organizationId; @JsonProperty("user_country") + @AuditLogIgnore private String country; @JsonProperty("user_tags") @@ -42,12 +48,15 @@ public class PlayerInput { @JsonProperty("user_phone") @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) + @AuditLogIgnore private String phone; @JsonProperty("user_phone2") @Pattern(regexp = PHONE_REGEXP, message = PHONE_FORMAT) + @AuditLogIgnore private String phone2; @JsonProperty("user_pgp_key") + @AuditLogHash private String pgpKey; } diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerOutput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerOutput.java index 3f1e2811bfb..65607de49cf 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerOutput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/player/PlayerOutput.java @@ -1,6 +1,8 @@ package io.openaev.rest.user.form.player; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogHash; +import io.openaev.database.audit.AuditLogIgnore; import jakarta.validation.constraints.NotBlank; import java.util.Set; import lombok.Builder; @@ -15,28 +17,35 @@ public class PlayerOutput { private String id; @JsonProperty("user_firstname") + @AuditLogIgnore private String firstname; @JsonProperty("user_lastname") + @AuditLogIgnore private String lastname; @JsonProperty("user_email") @NotBlank + @AuditLogIgnore private String email; @JsonProperty("user_organization") private String organization; @JsonProperty("user_country") + @AuditLogIgnore private String country; @JsonProperty("user_phone") + @AuditLogIgnore private String phone; @JsonProperty("user_phone2") + @AuditLogIgnore private String phone2; @JsonProperty("user_pgp_key") + @AuditLogHash private String pgpKey; @JsonProperty("user_tags") diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/user/ChangePasswordInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/user/ChangePasswordInput.java index d67b1a401cf..bfa8f90a70c 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/user/ChangePasswordInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/user/ChangePasswordInput.java @@ -3,6 +3,7 @@ import static io.openaev.config.AppConfig.MANDATORY_MESSAGE; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogRedact; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -13,10 +14,12 @@ public class ChangePasswordInput { @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("password") @Schema(description = "The new password") + @AuditLogRedact private String password; @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("password_validation") @Schema(description = "The new password again to validate it's been typed well") + @AuditLogRedact private String passwordValidation; } diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdatePasswordInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdatePasswordInput.java index 974aada7afb..fdcab45e260 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdatePasswordInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdatePasswordInput.java @@ -3,12 +3,14 @@ import static io.openaev.config.AppConfig.MANDATORY_MESSAGE; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogRedact; import jakarta.validation.constraints.NotBlank; public class UpdatePasswordInput { @NotBlank(message = MANDATORY_MESSAGE) @JsonProperty("user_plain_password") + @AuditLogRedact private String password; public String getPassword() { diff --git a/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdateUserInfoInput.java b/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdateUserInfoInput.java index 73f08afb08a..f3a47ae23c6 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdateUserInfoInput.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/form/user/UpdateUserInfoInput.java @@ -1,6 +1,8 @@ package io.openaev.rest.user.form.user; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogHash; +import io.openaev.database.audit.AuditLogIgnore; import lombok.Getter; import lombok.Setter; @@ -9,11 +11,14 @@ public class UpdateUserInfoInput { @JsonProperty("user_pgp_key") + @AuditLogHash private String pgpKey; @JsonProperty("user_phone") + @AuditLogIgnore private String phone; @JsonProperty("user_phone2") + @AuditLogIgnore private String phone2; } diff --git a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java index 4e4c90ab9b2..d9c62cddf58 100644 --- a/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java +++ b/openaev-api/src/main/java/io/openaev/utils/object/ObjectRedactionUtils.java @@ -8,13 +8,19 @@ import io.openaev.database.model.ResourceType; import java.util.Locale; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ObjectRedactionUtils { private ObjectRedactionUtils() {} - private static final String REDACTED = "*** Redacted ***"; + private static final Logger log = LoggerFactory.getLogger(ObjectRedactionUtils.class); + + private static final String REDACTED = "[REDACTED]"; + private static final Set WARNING_KEYS = ConcurrentHashMap.newKeySet(); /** Fields whose values are replaced with {@link #REDACTED} before logging. */ private static final Set SENSITIVE_FIELDS_REGEX_TO_REDACT = @@ -75,7 +81,7 @@ public static JsonNode redact(JsonNode node, ResourceType resourceType) { return node; } boolean isUserEntity = resourceType != null && USER_ENTITY_TYPES.contains(resourceType); - return redactNode(node, isUserEntity); + return redactNode(node, isUserEntity, resourceType); } public static Object redactFieldValue(Object value, String fieldName) { @@ -87,71 +93,101 @@ public static Object redactFieldValue(Object value, String fieldName) { fieldName = fieldName.toLowerCase(Locale.ROOT); if (USER_PII_FIELDS_TO_REMOVE.contains(fieldName)) { + warnMissingAuditAnnotation(fieldName, "@AuditLogIgnore", null, "remove_pii"); return null; } if (shouldHash(fieldName)) { + if (looksLikeSha256(stringValue)) { + return stringValue; + } + warnMissingAuditAnnotation(fieldName, "@AuditLogHash", null, "hash"); return hashWithSHA256(stringValue); } if (shouldRedact(fieldName)) { + if (REDACTED.equals(stringValue)) { + return stringValue; + } + warnMissingAuditAnnotation(fieldName, "@AuditLogRedact", null, "redact"); return REDACTED; } } return value; } - private static JsonNode redactNode(JsonNode node, boolean isUserEntity) { + private static JsonNode redactNode( + JsonNode node, boolean isUserEntity, ResourceType resourceType) { if (node == null || node.isNull()) { return node; } if (node instanceof ObjectNode original) { - return redactObjectNode(original, isUserEntity); + return redactObjectNode(original, isUserEntity, resourceType); } if (node instanceof ArrayNode original) { - return redactArrayNode(original, isUserEntity); + return redactArrayNode(original, isUserEntity, resourceType); } // Scalar nodes are immutable; returning as-is preserves value and avoids unnecessary copies. return node; } - private static ObjectNode redactObjectNode(ObjectNode original, boolean isUserEntity) { + private static ObjectNode redactObjectNode( + ObjectNode original, boolean isUserEntity, ResourceType resourceType) { ObjectNode result = original.objectNode(); original .properties() - .forEach(entry -> redactProperty(result, entry.getKey(), entry.getValue(), isUserEntity)); + .forEach( + entry -> + redactProperty( + result, entry.getKey(), entry.getValue(), isUserEntity, resourceType)); return result; } - private static ArrayNode redactArrayNode(ArrayNode original, boolean isUserEntity) { + private static ArrayNode redactArrayNode( + ArrayNode original, boolean isUserEntity, ResourceType resourceType) { ArrayNode result = original.arrayNode(); for (JsonNode element : original) { - result.add(redactNode(element, isUserEntity)); + result.add(redactNode(element, isUserEntity, resourceType)); } return result; } private static void redactProperty( - ObjectNode result, String key, JsonNode value, boolean isUserEntity) { + ObjectNode result, + String key, + JsonNode value, + boolean isUserEntity, + ResourceType resourceType) { String fieldName = key.toLowerCase(Locale.ROOT); if (isUserEntity && USER_PII_FIELDS_TO_REMOVE.contains(fieldName)) { + warnMissingAuditAnnotation(fieldName, "@AuditLogIgnore", resourceType, "remove_pii"); return; } if (shouldHash(fieldName)) { + if (isAlreadySha256(value)) { + result.set(key, value); + return; + } + warnMissingAuditAnnotation(fieldName, "@AuditLogHash", resourceType, "hash"); result.put(key, hashWithSHA256(toHashInput(value))); return; } if (shouldRedact(fieldName)) { + if (isAlreadyRedacted(value)) { + result.set(key, value); + return; + } + warnMissingAuditAnnotation(fieldName, "@AuditLogRedact", resourceType, "redact"); result.put(key, REDACTED); return; } - result.set(key, redactNode(value, isUserEntity)); + result.set(key, redactNode(value, isUserEntity, resourceType)); } private static boolean shouldHash(String fieldName) { @@ -174,4 +210,29 @@ private static String toHashInput(JsonNode value) { } return value.isTextual() ? value.textValue() : value.toString(); } + + private static boolean isAlreadyRedacted(JsonNode value) { + return value != null && value.isTextual() && REDACTED.equals(value.textValue()); + } + + private static boolean isAlreadySha256(JsonNode value) { + return value != null && value.isTextual() && looksLikeSha256(value.textValue()); + } + + private static boolean looksLikeSha256(String value) { + return value != null && value.matches("^[a-fA-F0-9]{64}$"); + } + + private static void warnMissingAuditAnnotation( + String fieldName, String expectedAnnotation, ResourceType resourceType, String operation) { + String warningKey = fieldName + "|" + operation + "|" + resourceType; + if (WARNING_KEYS.add(warningKey)) { + log.warn( + "[AUDIT] ObjectRedactionUtils fallback '{}' applied on field '{}' (resourceType='{}'). Consider adding {} on the corresponding model/DTO field.", + operation, + fieldName, + resourceType, + expectedAnnotation); + } + } } diff --git a/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java index acbd83a31d0..374d745e719 100644 --- a/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java +++ b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java @@ -1,10 +1,13 @@ package io.openaev.aop.audit_log; +import static io.openaev.helper.CryptoHelper.hashWithSHA256; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.AuditLogIgnore; +import io.openaev.database.audit.AuditLogRedact; import org.junit.jupiter.api.Test; class AuditObjectMapperTest { @@ -26,8 +29,53 @@ void given_auditIgnoredField_should_excludeItFromAuditPayload_only() { assertThat(auditJson.path("public_value").asText()).isEqualTo("visible"); } + @Test + void given_auditHashAndRedactFields_should_maskOnlyInAuditPayload() { + // Arrange + ObjectMapper httpMapper = new ObjectMapper(); + AuditObjectMapper auditMapper = new AuditObjectMapper(httpMapper); + SampleMaskedPayload payload = new SampleMaskedPayload("id-2", "api-token", "plain-secret"); + + // Act + var httpJson = httpMapper.valueToTree(payload); + var auditJson = auditMapper.valueToTree(payload); + + // Assert + assertThat(httpJson.path("token_value").asText()).isEqualTo("api-token"); + assertThat(httpJson.path("secret_value").asText()).isEqualTo("plain-secret"); + assertThat(auditJson.path("token_value").asText()).isEqualTo(hashWithSHA256("api-token")); + assertThat(auditJson.path("secret_value").asText()).isEqualTo("[REDACTED]"); + } + + @Test + void given_auditRemovedField_should_hideItFromAuditPayload_only() { + // Arrange + ObjectMapper httpMapper = new ObjectMapper(); + AuditObjectMapper auditMapper = new AuditObjectMapper(httpMapper); + SampleRemovedPayload payload = new SampleRemovedPayload("id-3", "john@company.tld", "visible"); + + // Act + var httpJson = httpMapper.valueToTree(payload); + var auditJson = auditMapper.valueToTree(payload); + + // Assert + assertThat(httpJson.path("user_email").asText()).isEqualTo("john@company.tld"); + assertThat(auditJson.path("user_email").isMissingNode()).isTrue(); + assertThat(auditJson.path("public_value").asText()).isEqualTo("visible"); + } + private record SamplePayload( @JsonProperty("payload_id") String id, @JsonProperty("public_value") String publicValue, @AuditLogIgnore @JsonProperty("secret_value") String secretValue) {} + + private record SampleMaskedPayload( + @JsonProperty("payload_id") String id, + @AuditLogHash @JsonProperty("token_value") String tokenValue, + @AuditLogRedact @JsonProperty("secret_value") String secretValue) {} + + private record SampleRemovedPayload( + @JsonProperty("payload_id") String id, + @AuditLogIgnore @JsonProperty("user_email") String email, + @JsonProperty("public_value") String publicValue) {} } diff --git a/openaev-api/src/test/java/io/openaev/service/LogServiceTest.java b/openaev-api/src/test/java/io/openaev/service/LogServiceTest.java index fbe5eab1006..3651b117200 100644 --- a/openaev-api/src/test/java/io/openaev/service/LogServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/service/LogServiceTest.java @@ -294,7 +294,7 @@ void given_eventWithEntityDiffs_should_includeDiffs() { @DisplayName("logRequestEvent — redaction of entity_diffs and signature") class LogRequestEventRedaction { - private static final String REDACTED = "*** Redacted ***"; + private static final String REDACTED = "[REDACTED]"; @BeforeEach void enableAudit() { diff --git a/openaev-model/src/main/java/io/openaev/database/audit/AuditLogHash.java b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogHash.java new file mode 100644 index 00000000000..1b2a25ed104 --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogHash.java @@ -0,0 +1,11 @@ +package io.openaev.database.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a field or getter to be hashed in audit-log payload serialization only. */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuditLogHash {} diff --git a/openaev-model/src/main/java/io/openaev/database/audit/AuditLogRedact.java b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogRedact.java new file mode 100644 index 00000000000..a34da9eb2cf --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/audit/AuditLogRedact.java @@ -0,0 +1,11 @@ +package io.openaev.database.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a field or getter to be redacted in audit-log payload serialization only. */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuditLogRedact {} diff --git a/openaev-model/src/main/java/io/openaev/database/model/Agent.java b/openaev-model/src/main/java/io/openaev/database/model/Agent.java index de00e19811f..bff9db30a1c 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Agent.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Agent.java @@ -33,7 +33,7 @@ @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public class Agent implements TenantBase, AuditStateCapturable { - public static final long ACTIVE_THRESHOLD_MILLIS = 3_600_000L; + public static final long ACTIVE_THRESHOLD_MILLIS = 6_000L; public static final String ADMIN_SYSTEM_WINDOWS = "nt authority\\system"; public static final String ADMIN_SYSTEM_UNIX = "root"; diff --git a/openaev-model/src/main/java/io/openaev/database/model/Asset.java b/openaev-model/src/main/java/io/openaev/database/model/Asset.java index 263776d8704..fb2a598f84e 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Asset.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Asset.java @@ -12,6 +12,7 @@ import io.openaev.annotation.DomainConstraint; import io.openaev.annotation.Ipv4OrIpv6Constraint; import io.openaev.annotation.Queryable; +import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.AuditLogIgnore; import io.openaev.database.audit.AuditStateIgnore; import io.openaev.database.audit.ModelBaseListener; @@ -230,6 +231,7 @@ public void setLinkedPerson(String linkedPerson) { @Type(StringArrayType.class) @Column(name = "asset_mac_addresses") @JsonProperty("asset_mac_addresses") + @AuditLogHash private String[] macAddresses; public void setHostname(String hostname) { @@ -284,6 +286,7 @@ public void setHostname(String hostname) { */ @Column(name = "ai_target_token") @JsonProperty("ai_target_token") + @AuditLogHash private String aiTargetToken; /** diff --git a/openaev-model/src/main/java/io/openaev/database/model/PhishingResult.java b/openaev-model/src/main/java/io/openaev/database/model/PhishingResult.java index 8583aad967d..f9c67cedddc 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/PhishingResult.java +++ b/openaev-model/src/main/java/io/openaev/database/model/PhishingResult.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.ModelBaseListener; import io.openaev.database.audit.TenantBaseListener; import io.openaev.helper.MonoIdSerializer; @@ -62,6 +63,7 @@ public class PhishingResult implements TenantBase { @Column(name = "phishing_result_token", unique = true) @JsonProperty("phishing_result_token") @NotBlank + @AuditLogHash private String token; @ManyToOne(fetch = FetchType.LAZY) diff --git a/openaev-model/src/main/java/io/openaev/database/model/TenantXtmHubRegistration.java b/openaev-model/src/main/java/io/openaev/database/model/TenantXtmHubRegistration.java index 221cde5d040..92f21148980 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/TenantXtmHubRegistration.java +++ b/openaev-model/src/main/java/io/openaev/database/model/TenantXtmHubRegistration.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.ModelBaseListener; import io.openaev.database.audit.TenantBaseListener; import io.openaev.xtmhub.XtmHubRegistrationStatus; @@ -29,6 +30,7 @@ public class TenantXtmHubRegistration implements TenantBase { @Column(name = "registration_token") @JsonProperty("registration_token") + @AuditLogHash private String token; @Column(name = "registration_date") diff --git a/openaev-model/src/main/java/io/openaev/database/model/User.java b/openaev-model/src/main/java/io/openaev/database/model/User.java index ff679945300..0960909d930 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/User.java +++ b/openaev-model/src/main/java/io/openaev/database/model/User.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.openaev.annotation.Queryable; import io.openaev.context.TenantContext; +import io.openaev.database.audit.AuditLogHash; +import io.openaev.database.audit.AuditLogIgnore; +import io.openaev.database.audit.AuditLogRedact; import io.openaev.database.audit.ModelBaseListener; import io.openaev.helper.*; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -61,6 +64,7 @@ public class User implements Base { @JsonProperty("user_firstname") @Queryable(filterable = true, searchable = true, sortable = true) @Schema(description = "First name of the user") + @AuditLogIgnore private String firstname; @Setter @@ -68,6 +72,7 @@ public class User implements Base { @JsonProperty("user_lastname") @Queryable(filterable = true, searchable = true, sortable = true) @Schema(description = "Last name of the user") + @AuditLogIgnore private String lastname; @Getter(NONE) @@ -75,6 +80,7 @@ public class User implements Base { @Column(name = "user_lang") @JsonProperty("user_lang") @Schema(description = "Language of the user") + @AuditLogIgnore private String lang = LANG_AUTO; public String getLang() { @@ -113,6 +119,7 @@ public void setHomeDashboard(final String homeDashboard) { @Queryable(filterable = true, searchable = true, sortable = true) @NotBlank @Schema(description = "Email of the user") + @AuditLogIgnore private String email; public void setEmail(final String email) { @@ -130,18 +137,21 @@ public String getEmail() { @Column(name = "user_phone") @JsonProperty("user_phone") @Schema(description = "Phone number of the user") + @AuditLogIgnore private String phone; @Setter @Column(name = "user_phone2") @JsonProperty("user_phone2") @Schema(description = "Secondary phone number of the user") + @AuditLogIgnore private String phone2; @Setter @Column(name = "user_pgp_key") @JsonProperty("user_pgp_key") @Schema(description = "PGP key of the user") + @AuditLogHash private String pgpKey; @Setter @@ -154,6 +164,7 @@ public String getEmail() { @Setter @Column(name = "user_password") @JsonIgnore + @AuditLogRedact private String password; @Setter @@ -190,12 +201,14 @@ public String getEmail() { @Column(name = "user_country") @JsonProperty("user_country") @Schema(description = "Country of the user") + @AuditLogIgnore private String country; @Setter @Column(name = "user_city") @JsonProperty("user_city") @Schema(description = "City of the user") + @AuditLogIgnore private String city; // -- RELATIONS -- @@ -268,6 +281,7 @@ public List getScopedGroups() { inverseJoinColumns = @JoinColumn(name = "communication_id")) @JsonSerialize(using = MultiIdListSerializer.class) @JsonProperty("user_communications") + @AuditLogIgnore private List communications = new ArrayList<>(); @ArraySchema( From 0201ad0d23eba1367008bc82852ffe263380276a Mon Sep 17 00:00:00 2001 From: Gael Leblan Date: Fri, 28 Aug 2026 17:11:57 +0200 Subject: [PATCH 2/2] feat(audit-log): add an annotation to hash or redact a property to audit log (#0000) --- .../aop/audit_log/AuditObjectMapper.java | 34 ++++++++++++- .../aop/audit_log/AuditObjectMapperTest.java | 48 +++++++++++++++++++ .../java/io/openaev/database/model/Agent.java | 2 +- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java index 215805eda8a..215f3f748e9 100644 --- a/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditObjectMapper.java @@ -18,6 +18,8 @@ import io.openaev.database.audit.AuditLogIgnore; import io.openaev.database.audit.AuditLogRedact; import java.io.IOException; +import java.lang.reflect.Array; +import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Component; @@ -74,6 +76,7 @@ public List changeProperties( private static class AuditHashSerializer extends StdSerializer { private static final AuditHashSerializer INSTANCE = new AuditHashSerializer(); + private static final ObjectMapper HASH_INPUT_MAPPER = new ObjectMapper(); private AuditHashSerializer() { super(TypeFactory.defaultInstance().constructType(Object.class)); @@ -87,7 +90,36 @@ public void serialize( provider.defaultSerializeNull(gen); return; } - gen.writeString(hashWithSHA256(String.valueOf(value))); + gen.writeString(hashWithSHA256(toHashInput(value))); + } + + private static String toHashInput(Object value) throws IOException { + if (value.getClass().isArray() || value instanceof List) { + return HASH_INPUT_MAPPER.writeValueAsString(normalizeStructuredValue(value)); + } + return String.valueOf(value); + } + + private static Object normalizeStructuredValue(Object value) { + if (value == null) { + return null; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + List normalized = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + normalized.add(normalizeStructuredValue(Array.get(value, index))); + } + return normalized; + } + if (value instanceof List listValue) { + List normalized = new ArrayList<>(listValue.size()); + for (Object element : listValue) { + normalized.add(normalizeStructuredValue(element)); + } + return normalized; + } + return value; } } diff --git a/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java index 374d745e719..744a69aeeb5 100644 --- a/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java +++ b/openaev-api/src/test/java/io/openaev/aop/audit_log/AuditObjectMapperTest.java @@ -8,6 +8,7 @@ import io.openaev.database.audit.AuditLogHash; import io.openaev.database.audit.AuditLogIgnore; import io.openaev.database.audit.AuditLogRedact; +import java.util.List; import org.junit.jupiter.api.Test; class AuditObjectMapperTest { @@ -64,6 +65,45 @@ void given_auditRemovedField_should_hideItFromAuditPayload_only() { assertThat(auditJson.path("public_value").asText()).isEqualTo("visible"); } + @Test + void given_auditHashArrayField_should_hashDeterministicallyFromArrayContent() throws Exception { + // Arrange + ObjectMapper httpMapper = new ObjectMapper(); + AuditObjectMapper auditMapper = new AuditObjectMapper(httpMapper); + SampleArrayHashedPayload payload1 = + new SampleArrayHashedPayload("id-4", new String[] {"aa", "bb"}); + SampleArrayHashedPayload payload2 = + new SampleArrayHashedPayload("id-5", new String[] {"aa", "bb"}); + + // Act + var auditJson1 = auditMapper.valueToTree(payload1); + var auditJson2 = auditMapper.valueToTree(payload2); + + // Assert + String expectedHash = hashWithSHA256(httpMapper.writeValueAsString(List.of("aa", "bb"))); + assertThat(auditJson1.path("mac_addresses").asText()).isEqualTo(expectedHash); + assertThat(auditJson2.path("mac_addresses").asText()).isEqualTo(expectedHash); + assertThat(auditJson1.path("mac_addresses").asText()).doesNotContain("[L"); + } + + @Test + void given_auditHashListField_should_hashDeterministicallyFromListContent() throws Exception { + // Arrange + ObjectMapper httpMapper = new ObjectMapper(); + AuditObjectMapper auditMapper = new AuditObjectMapper(httpMapper); + SampleListHashedPayload payload1 = new SampleListHashedPayload("id-6", List.of("aa", "bb")); + SampleListHashedPayload payload2 = new SampleListHashedPayload("id-7", List.of("aa", "bb")); + + // Act + var auditJson1 = auditMapper.valueToTree(payload1); + var auditJson2 = auditMapper.valueToTree(payload2); + + // Assert + String expectedHash = hashWithSHA256(httpMapper.writeValueAsString(List.of("aa", "bb"))); + assertThat(auditJson1.path("tags").asText()).isEqualTo(expectedHash); + assertThat(auditJson2.path("tags").asText()).isEqualTo(expectedHash); + } + private record SamplePayload( @JsonProperty("payload_id") String id, @JsonProperty("public_value") String publicValue, @@ -78,4 +118,12 @@ private record SampleRemovedPayload( @JsonProperty("payload_id") String id, @AuditLogIgnore @JsonProperty("user_email") String email, @JsonProperty("public_value") String publicValue) {} + + private record SampleArrayHashedPayload( + @JsonProperty("payload_id") String id, + @AuditLogHash @JsonProperty("mac_addresses") String[] macAddresses) {} + + private record SampleListHashedPayload( + @JsonProperty("payload_id") String id, + @AuditLogHash @JsonProperty("tags") List tags) {} } diff --git a/openaev-model/src/main/java/io/openaev/database/model/Agent.java b/openaev-model/src/main/java/io/openaev/database/model/Agent.java index bff9db30a1c..de00e19811f 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Agent.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Agent.java @@ -33,7 +33,7 @@ @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public class Agent implements TenantBase, AuditStateCapturable { - public static final long ACTIVE_THRESHOLD_MILLIS = 6_000L; + public static final long ACTIVE_THRESHOLD_MILLIS = 3_600_000L; public static final String ADMIN_SYSTEM_WINDOWS = "nt authority\\system"; public static final String ADMIN_SYSTEM_UNIX = "root";