From 7f46f58a6ef4df424ae21521f251bedb33a37cff Mon Sep 17 00:00:00 2001 From: Anton Kurako Date: Sat, 18 Jul 2026 13:32:09 +0300 Subject: [PATCH 1/3] Added structured log JSON masking with generated metadata Added automatic JSON structured argument mapping and masked JSON logging for annotated arguments and results. Mask metadata is generated for Java AP and KSP models annotated with `@Mask`, including nested record/data-class structures and generic container traversal. - Added `@Mask` rules with FULL, KEEP_FIRST, and KEEP_LAST modes. - Added default `@Json` and `@Tag(Mask.class)` StructuredArgumentMapper providers. - Added runtime masking support through generated MaskingMetadata and masking JsonGenerator delegation. - Improved tag-only mapper lookup so @Json can resolve tagged StructuredArgumentMapper instances without an explicit mapper class. --- .../processor/common/CommonUtils.java | 4 +- .../logging-annotation-processor/build.gradle | 2 + .../processor/LoggingAnnotationProcessor.java | 59 ++++ .../processor/MaskingMetadataProcessor.java | 315 +++++++++++++++++ .../processor/aop}/LogAspect.java | 30 +- .../processor/aop/LogAspectClassNames.java | 25 ++ .../processor/aop}/LogAspectFactory.java | 2 +- .../processor/aop}/LogAspectUtils.java | 4 +- .../processor/aop}/MdcAspect.java | 4 +- .../processor/aop}/MdcAspectClassNames.java | 2 +- .../processor/aop}/MdcAspectFactory.java | 2 +- .../logging/aspect/LogAspectClassNames.java | 16 - ...aop.annotation.processor.KoraAspectFactory | 4 +- .../logging/aspect/AbstractLogAspectTest.java | 3 + .../logging/aspect/LogAspectTest.java | 246 ++++++++++++++ .../logging/common/LoggingModule.java | 21 +- .../logging/common/annotation/Mask.java | 23 ++ .../arg/JsonStructuredArgumentMapper.java | 17 + .../arg/MaskedStructuredArgumentMapper.java | 21 ++ .../logging/common/masking/MaskRule.java | 39 +++ .../common/masking/MaskingClassMeta.java | 12 + .../common/masking/MaskingFieldMeta.java | 28 ++ .../common/masking/MaskingJsonGenerator.java | 318 ++++++++++++++++++ .../common/masking/MaskingMetadata.java | 8 + .../src/main/java/module-info.java | 1 + logging/logging-symbol-processor/build.gradle | 3 + .../MaskingMetadataSymbolProcessor.kt | 250 ++++++++++++++ .../MaskingMetadataSymbolProcessorProvider.kt | 8 + .../symbol/processor/aop/LogKoraAspect.kt | 37 +- .../processor/aop/{mdc => }/MdcKoraAspect.kt | 2 +- .../aop/{mdc => }/MdcKoraAspectFactory.kt | 2 +- .../processor/aop/{data => }/MethodData.kt | 2 +- .../aop/{data => }/MethodParameterData.kt | 2 +- ...ork.aop.symbol.processor.KoraAspectFactory | 2 +- .../processor/aop/AbstractLogAspectTest.kt | 13 + .../symbol/processor/aop/LogAspectTest.kt | 129 +++++++ 36 files changed, 1602 insertions(+), 54 deletions(-) create mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java create mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect => annotation/processor/aop}/LogAspect.java (92%) create mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect => annotation/processor/aop}/LogAspectFactory.java (88%) rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect => annotation/processor/aop}/LogAspectUtils.java (96%) rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect/mdc => annotation/processor/aop}/MdcAspect.java (97%) rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect/mdc => annotation/processor/aop}/MdcAspectClassNames.java (88%) rename logging/logging-annotation-processor/src/main/java/io/koraframework/logging/{aspect/mdc => annotation/processor/aop}/MdcAspectFactory.java (87%) delete mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectClassNames.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/arg/JsonStructuredArgumentMapper.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java create mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt create mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt rename logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/{mdc => }/MdcKoraAspect.kt (99%) rename logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/{mdc => }/MdcKoraAspectFactory.kt (83%) rename logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/{data => }/MethodData.kt (81%) rename logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/{data => }/MethodParameterData.kt (65%) diff --git a/core/annotation-processor-common/src/main/java/io/koraframework/annotation/processor/common/CommonUtils.java b/core/annotation-processor-common/src/main/java/io/koraframework/annotation/processor/common/CommonUtils.java index ce10ae81a..227658412 100644 --- a/core/annotation-processor-common/src/main/java/io/koraframework/annotation/processor/common/CommonUtils.java +++ b/core/annotation-processor-common/src/main/java/io/koraframework/annotation/processor/common/CommonUtils.java @@ -177,10 +177,10 @@ public MappingData getMapping(Types types, TypeMirror type) { @Nullable public MappingData getMapping(ClassName type) { - if (this.mapperClasses == null) { + if (this.mapperClasses == null && this.tag == null) { return null; } - for (var mapperClass : mapperClasses) { + for (var mapperClass : Objects.requireNonNullElse(mapperClasses, List.of())) { if (doesImplement(mapperClass, type)) { return new MappingData(mapperClass, this.tag); } diff --git a/logging/logging-annotation-processor/build.gradle b/logging/logging-annotation-processor/build.gradle index 5953044ed..142baa7c7 100644 --- a/logging/logging-annotation-processor/build.gradle +++ b/logging/logging-annotation-processor/build.gradle @@ -2,6 +2,8 @@ dependencies { implementation project(':aop:aop-annotation-processor') testImplementation project(':logging:logging-common') + testImplementation project(':json:json-annotation-processor') + testImplementation project(':core:kora-app-annotation-processor') testImplementation testFixtures(project(":core:annotation-processor-common")) testImplementation project(':logging:logging-logback') } diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java new file mode 100644 index 000000000..2c85c1b26 --- /dev/null +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java @@ -0,0 +1,59 @@ +package io.koraframework.logging.annotation.processor; + +import com.palantir.javapoet.ClassName; +import io.koraframework.annotation.processor.common.AbstractKoraProcessor; +import io.koraframework.annotation.processor.common.AnnotationUtils; +import io.koraframework.annotation.processor.common.ProcessingErrorException; +import io.koraframework.logging.annotation.processor.aop.LogAspectClassNames; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.tools.Diagnostic; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class LoggingAnnotationProcessor extends AbstractKoraProcessor { + + private MaskingMetadataProcessor maskingMetadataProcessor; + + @Override + public Set getSupportedAnnotationClassNames() { + return Set.of(LogAspectClassNames.mask); + } + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.maskingMetadataProcessor = new MaskingMetadataProcessor(processingEnv); + } + + @Override + protected void process(Set annotations, RoundEnvironment roundEnv, Map> annotatedElements) { + var maskedElements = annotatedElements.getOrDefault(LogAspectClassNames.mask, List.of()); + for (var annotated : maskedElements) { + var element = annotated.element(); + if (!(element instanceof TypeElement typeElement)) { + continue; + } + if (!typeElement.getKind().isClass()) { + this.messager.printMessage(Diagnostic.Kind.ERROR, "Only classes and records can be annotated with @Mask", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + this.messager.printMessage(Diagnostic.Kind.ERROR, "Abstract classes can't be annotated with @Mask", element); + continue; + } + if (AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.mask) == null) { + continue; + } + try { + this.maskingMetadataProcessor.generate(typeElement); + } catch (ProcessingErrorException e) { + e.printError(this.processingEnv); + } + } + } +} diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java new file mode 100644 index 000000000..7ad0ca5c8 --- /dev/null +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java @@ -0,0 +1,315 @@ +package io.koraframework.logging.annotation.processor; + +import com.palantir.javapoet.*; +import io.koraframework.annotation.processor.common.*; +import io.koraframework.logging.annotation.processor.aop.LogAspectClassNames; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.RecordComponentElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import java.util.*; + +public final class MaskingMetadataProcessor { + + private static final ClassName MAP = ClassName.get(Map.class); + private static final ClassName CLASS = ClassName.get(Class.class); + private static final ClassName MASK_MODE = LogAspectClassNames.mask.nestedClass("Mode"); + + private final ProcessingEnvironment env; + private final Elements elements; + private final Types types; + + public MaskingMetadataProcessor(ProcessingEnvironment env) { + this.env = env; + this.elements = env.getElementUtils(); + this.types = env.getTypeUtils(); + } + + public void generate(TypeElement root) { + var packageName = this.elements.getPackageOf(root).getQualifiedName().toString(); + var className = metadataName(root); + if (this.elements.getTypeElement(packageName + "." + className) != null) { + return; + } + + var visited = new LinkedHashMap(); + this.visit(root, visited); + + var rootType = TypeName.get(root.asType()); + var metadataType = ParameterizedTypeName.get(LogAspectClassNames.maskingMetadata, rootType); + var type = TypeSpec.classBuilder(className) + .addModifiers(javax.lang.model.element.Modifier.PUBLIC, javax.lang.model.element.Modifier.FINAL) + .addAnnotation(CommonClassNames.component) + .addAnnotation(AnnotationUtils.generated(MaskingMetadataProcessor.class)) + .addSuperinterface(metadataType) + .addField(FieldSpec.builder( + ParameterizedTypeName.get(MAP, ParameterizedTypeName.get(CLASS, WildcardTypeName.subtypeOf(Object.class)), LogAspectClassNames.maskingClassMeta), + "metadata", + javax.lang.model.element.Modifier.PRIVATE, + javax.lang.model.element.Modifier.FINAL + ).build()) + .addMethod(this.constructor(visited.values())) + .addMethod(this.metadataMethod()) + .build(); + + CommonUtils.safeWriteTo(this.env, JavaFile.builder(packageName, type).build()); + } + + private MethodSpec constructor(Collection metas) { + var method = MethodSpec.constructorBuilder() + .addModifiers(javax.lang.model.element.Modifier.PUBLIC); + method.addCode("this.metadata = $L;\n", this.metadataCode(metas)); + return method.build(); + } + + private CodeBlock metadataCode(Collection metas) { + if (metas.isEmpty()) { + return CodeBlock.of("$T.of()", MAP); + } + + var code = CodeBlock.builder().add("$T.ofEntries(\n$>", MAP); + var iterator = metas.iterator(); + while (iterator.hasNext()) { + var meta = iterator.next(); + code.add("$T.entry($T.class, new $T($L))", MAP, TypeName.get(meta.type().asType()), LogAspectClassNames.maskingClassMeta, this.fieldsCode(meta)); + if (iterator.hasNext()) { + code.add(",\n"); + } + } + return code.add("$<\n)").build(); + } + + private MethodSpec metadataMethod() { + return MethodSpec.methodBuilder("metadata") + .addAnnotation(Override.class) + .addModifiers(javax.lang.model.element.Modifier.PUBLIC) + .returns(LogAspectClassNames.maskingClassMeta) + .addParameter(ParameterizedTypeName.get(CLASS, WildcardTypeName.subtypeOf(Object.class)), "type") + .addStatement("return this.metadata.get(type)") + .build(); + } + + private CodeBlock fieldsCode(MaskingClassMeta meta) { + var fields = new ArrayList(); + var typeMask = findMask(meta.type()); + for (var field : meta.fields()) { + var mask = field.mask(); + if (mask != null) { + fields.add(CodeBlock.of("$S, $T.mask($L)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, this.maskRuleCode(mask, typeMask))); + continue; + } + var nestedType = this.nestedType(field.type()); + if (nestedType == null) { + continue; + } + if (CommonUtils.isCollection(field.type())) { + fields.add(CodeBlock.of("$S, $T.collection($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); + } else if (CommonUtils.isMap(field.type())) { + fields.add(CodeBlock.of("$S, $T.mapValue($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); + } else { + fields.add(CodeBlock.of("$S, $T.object($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); + } + } + if (fields.isEmpty()) { + return CodeBlock.of("$T.of()", MAP); + } + + var code = CodeBlock.builder().add("$T.ofEntries(\n$>", MAP); + for (int i = 0; i < fields.size(); i++) { + code.add("$T.entry($L)", MAP, fields.get(i)); + if (i < fields.size() - 1) { + code.add(",\n"); + } + } + return code.add("$<\n)").build(); + } + + private CodeBlock maskRuleCode(AnnotationMirror mask, AnnotationMirror typeMask) { + var value = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "value"); + if (value == null) { + value = AnnotationUtils.parseAnnotationValue(this.elements, typeMask, "value"); + } + if (value == null) { + value = "***"; + } + + var mode = this.mode(mask); + if (mode == null) { + mode = this.mode(typeMask); + } + if (mode == null) { + mode = "FULL"; + } + + var keep = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "keep"); + if (keep == null) { + keep = AnnotationUtils.parseAnnotationValue(this.elements, typeMask, "keep"); + } + if (keep == null) { + keep = 4; + } + + return CodeBlock.of("$T.replacement($S, $T.$L, $L)", LogAspectClassNames.maskRule, value, MASK_MODE, mode, keep); + } + + private String mode(AnnotationMirror mask) { + var mode = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "mode"); + return mode == null ? null : mode.getSimpleName().toString(); + } + + private void visit(TypeElement type, Map visited) { + var key = type.getQualifiedName().toString(); + if (visited.containsKey(key)) { + return; + } + if (!this.isJsonOrMasked(type)) { + return; + } + var meta = this.parse(type); + visited.put(key, meta); + for (var field : meta.fields()) { + if (field.mask() != null) { + continue; + } + for (var nested : this.nestedTypes(field.type())) { + this.visit(nested, visited); + } + } + } + + private MaskingClassMeta parse(TypeElement type) { + var fields = new ArrayList(); + var nameConverter = CommonUtils.getNameConverter(type); + if (type.getKind() == ElementKind.RECORD) { + for (var enclosed : type.getEnclosedElements()) { + if (!(enclosed instanceof RecordComponentElement component)) { + continue; + } + if (AnnotationUtils.findAnnotation(component, LogAspectClassNames.jsonSkip) != null) { + continue; + } + var accessor = this.findRecordAccessor(type, component); + var jsonName = this.jsonName(component, nameConverter); + var mask = findMask(component, accessor); + fields.add(new MaskingField(jsonName, component.asType(), mask)); + } + } else { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD || !(enclosed instanceof VariableElement field)) { + continue; + } + if (field.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) { + continue; + } + if (AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonSkip) != null) { + continue; + } + var jsonName = this.jsonName(field, nameConverter); + var accessor = this.findAccessor(type, field); + var mask = accessor == null ? findMask(field) : findMask(field, accessor); + fields.add(new MaskingField(jsonName, field.asType(), mask)); + } + } + return new MaskingClassMeta(type, fields); + } + + private String jsonName(Element field, CommonUtils.NameConverter nameConverter) { + var jsonField = AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonField); + if (jsonField != null) { + var value = AnnotationUtils.parseAnnotationValueWithoutDefault(jsonField, "value"); + if (value != null && !value.isBlank()) { + return value; + } + } + var name = field.getSimpleName().toString(); + return nameConverter == null ? name : nameConverter.convert(name); + } + + private ExecutableElement findRecordAccessor(TypeElement type, RecordComponentElement component) { + return CommonUtils.findMethods(type, m -> m.contains(javax.lang.model.element.Modifier.PUBLIC)) + .stream() + .filter(m -> m.getParameters().isEmpty()) + .filter(m -> m.getSimpleName().contentEquals(component.getSimpleName())) + .findFirst() + .orElse(null); + } + + private ExecutableElement findAccessor(TypeElement type, VariableElement field) { + var name = field.getSimpleName().toString(); + var capitalized = CommonUtils.capitalize(name); + return CommonUtils.findMethods(type, m -> m.contains(javax.lang.model.element.Modifier.PUBLIC)) + .stream() + .filter(m -> m.getParameters().isEmpty()) + .filter(m -> m.getSimpleName().contentEquals(name) || m.getSimpleName().contentEquals("get" + capitalized)) + .findFirst() + .orElse(null); + } + + private TypeElement nestedType(TypeMirror type) { + var nestedTypes = this.nestedTypes(type); + if (nestedTypes.size() != 1) { + return null; + } + return nestedTypes.get(0); + } + + private List nestedTypes(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED || !(type instanceof DeclaredType declaredType)) { + return List.of(); + } + if (CommonUtils.isCollection(type)) { + if (declaredType.getTypeArguments().isEmpty()) { + return List.of(); + } + return this.nestedTypes(declaredType.getTypeArguments().get(0)); + } else if (CommonUtils.isMap(type)) { + if (declaredType.getTypeArguments().size() < 2) { + return List.of(); + } + return this.nestedTypes(declaredType.getTypeArguments().get(1)); + } + + var element = this.types.asElement(type); + if (!(element instanceof TypeElement typeElement)) { + return List.of(); + } + if (!this.isJsonOrMasked(typeElement)) { + return List.of(); + } + return List.of(typeElement); + } + + private boolean isJsonOrMasked(TypeElement typeElement) { + return AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.json) != null + || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.jsonWriter) != null + || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.mask) != null; + } + + private static AnnotationMirror findMask(Element... elements) { + for (var element : elements) { + var mask = AnnotationUtils.findAnnotation(element, LogAspectClassNames.mask); + if (mask != null) { + return mask; + } + } + return null; + } + + public static String metadataName(TypeElement type) { + return NameUtils.generatedType(type, "MaskingMetadata"); + } + + private record MaskingClassMeta(TypeElement type, List fields) {} + + private record MaskingField(String jsonName, TypeMirror type, AnnotationMirror mask) {} +} diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspect.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java similarity index 92% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspect.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java index dae5ba37e..9ed24066d 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspect.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect; +package io.koraframework.logging.annotation.processor.aop; import com.palantir.javapoet.ClassName; import com.palantir.javapoet.CodeBlock; @@ -9,13 +9,14 @@ import io.koraframework.aop.annotation.processor.KoraAspect; import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeKind; import java.util.*; -import static io.koraframework.logging.aspect.LogAspectClassNames.*; -import static io.koraframework.logging.aspect.LogAspectUtils.*; +import static io.koraframework.logging.annotation.processor.aop.LogAspectClassNames.*; +import static io.koraframework.logging.annotation.processor.aop.LogAspectUtils.*; public class LogAspect implements KoraAspect { @@ -84,13 +85,13 @@ private ApplyResult blockingBody(AspectContext aspectContext, ExecutableElement var logResultLevel = logResultLevel(executableElement, logOutLevel, env); final CodeBlock resultWriter; if (!isVoid && logResultLevel != null) { - var mapping = CommonUtils.parseMapping(executableElement).getMapping(structuredArgumentMapper); + var mapping = this.structuredArgumentMapping(executableElement); var mapperType = mapping != null && mapping.mapperClass() != null ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(executableElement.getReturnType())) : TypeName.get(mapping.mapperClass()) : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(executableElement.getReturnType()).box()); var mapper = aspectContext.fieldFactory().constructorParam( mapperType.annotated(CommonClassNames.nullableAnnotation), - List.of() + mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) ); var resultWriterBuilder = CodeBlock.builder().beginControlFlow("gen ->") .addStatement("gen.writeStartObject()") @@ -172,13 +173,13 @@ private ApplyResult completionStageBody(AspectContext aspectContext, ExecutableE final CodeBlock resultWriter; b.beginControlFlow(".whenComplete(($L, $L) -> ", RESULT_VAR_NAME, ERROR_VAR_NAME); if (!isVoid && logResultLevel != null) { - var mapping = CommonUtils.parseMapping(executableElement).getMapping(structuredArgumentMapper); + var mapping = this.structuredArgumentMapping(executableElement); var mapperType = mapping != null && mapping.mapperClass() != null ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(methodGeneric)) : TypeName.get(mapping.mapperClass()) : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(methodGeneric)); var mapper = aspectContext.fieldFactory().constructorParam( mapperType.annotated(CommonClassNames.nullableAnnotation), - List.of() + mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) ); var resultWriterBuilder = CodeBlock.builder().add("gen -> {$>\n") .add("gen.writeStartObject();\n") @@ -309,13 +310,13 @@ private LogInMarker logInMarker(AspectContext aspectContext, String loggerField, b.beginControlFlow("if ($N.$N())", loggerField, "is" + CommonUtils.capitalize(level.toLowerCase()) + "Enabled"); } for (var param : paramsForLevel) { - var mapping = CommonUtils.parseMapping(param).getMapping(structuredArgumentMapper); + var mapping = this.structuredArgumentMapping(param); var mapperType = mapping != null && mapping.mapperClass() != null ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(param.asType())) : TypeName.get(mapping.mapperClass()) : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(param.asType()).box()); var mapper = aspectContext.fieldFactory().constructorParam( mapperType.annotated(CommonClassNames.nullableAnnotation), - List.of() + mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) ); b.beginControlFlow("if (this.$N != null)", mapper); b.addStatement("gen.writeName($S)", param.getSimpleName()); @@ -339,4 +340,15 @@ private CodeBlock.Builder ifLogLevelEnabled(CodeBlock.Builder cb, String loggerF cb.add("$<\n}\n"); return cb; } + + private CommonUtils.MappingData structuredArgumentMapping(Element element) { + var mapping = CommonUtils.parseMapping(element).getMapping(structuredArgumentMapper); + if (mapping != null) { + return mapping; + } + if (AnnotationUtils.findAnnotation(element, mask) != null) { + return new CommonUtils.MappingData(null, mask.canonicalName()); + } + return null; + } } diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java new file mode 100644 index 000000000..6cb9e1458 --- /dev/null +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java @@ -0,0 +1,25 @@ +package io.koraframework.logging.annotation.processor.aop; + +import com.palantir.javapoet.ClassName; + +public class LogAspectClassNames { + public static final ClassName log = ClassName.get("io.koraframework.logging.common.annotation", "Log"); + public static final ClassName logIn = log.nestedClass("in"); + public static final ClassName logOut = log.nestedClass("out"); + public static final ClassName logOff = log.nestedClass("off"); + public static final ClassName logResult = log.nestedClass("result"); + public static final ClassName mask = ClassName.get("io.koraframework.logging.common.annotation", "Mask"); + public static final ClassName json = ClassName.get("io.koraframework.json.common.annotation", "Json"); + public static final ClassName jsonWriter = ClassName.get("io.koraframework.json.common.annotation", "JsonWriter"); + public static final ClassName jsonField = ClassName.get("io.koraframework.json.common.annotation", "JsonField"); + public static final ClassName jsonSkip = ClassName.get("io.koraframework.json.common.annotation", "JsonSkip"); + public static final ClassName structuredArgument = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgument"); + public static final ClassName structuredArgumentMapper = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgumentMapper"); + public static final ClassName maskingMetadata = ClassName.get("io.koraframework.logging.common.masking", "MaskingMetadata"); + public static final ClassName maskingClassMeta = ClassName.get("io.koraframework.logging.common.masking", "MaskingClassMeta"); + public static final ClassName maskingFieldMeta = ClassName.get("io.koraframework.logging.common.masking", "MaskingFieldMeta"); + public static final ClassName maskRule = ClassName.get("io.koraframework.logging.common.masking", "MaskRule"); + + public static final ClassName loggerFactory = ClassName.get("org.slf4j", "ILoggerFactory"); + public static final ClassName logger = ClassName.get("org.slf4j", "Logger"); +} diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectFactory.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectFactory.java similarity index 88% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectFactory.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectFactory.java index 793dbfeb0..a089a7858 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectFactory.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectFactory.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect; +package io.koraframework.logging.annotation.processor.aop; import io.koraframework.aop.annotation.processor.KoraAspect; import io.koraframework.aop.annotation.processor.KoraAspectFactory; diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectUtils.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectUtils.java similarity index 96% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectUtils.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectUtils.java index f0074c7e9..add1af92a 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectUtils.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectUtils.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect; +package io.koraframework.logging.annotation.processor.aop; import org.jspecify.annotations.Nullable; import io.koraframework.annotation.processor.common.AnnotationUtils; @@ -10,7 +10,7 @@ import java.util.List; import java.util.Optional; -import static io.koraframework.logging.aspect.LogAspectClassNames.*; +import static io.koraframework.logging.annotation.processor.aop.LogAspectClassNames.*; public class LogAspectUtils { public static final List LEVELS = List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE"); diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspect.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspect.java similarity index 97% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspect.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspect.java index b748f198c..d5dbc3b99 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspect.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspect.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect.mdc; +package io.koraframework.logging.annotation.processor.aop; import com.palantir.javapoet.ClassName; import com.palantir.javapoet.CodeBlock; @@ -16,7 +16,7 @@ import java.util.Set; import static io.koraframework.annotation.processor.common.AnnotationUtils.*; -import static io.koraframework.logging.aspect.mdc.MdcAspectClassNames.*; +import static io.koraframework.logging.annotation.processor.aop.MdcAspectClassNames.*; public class MdcAspect implements KoraAspect { diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectClassNames.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectClassNames.java similarity index 88% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectClassNames.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectClassNames.java index cc08d22fe..9792bcae2 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectClassNames.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectClassNames.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect.mdc; +package io.koraframework.logging.annotation.processor.aop; import com.palantir.javapoet.ClassName; diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectFactory.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectFactory.java similarity index 87% rename from logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectFactory.java rename to logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectFactory.java index 6ce1fb94d..14566a24a 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/mdc/MdcAspectFactory.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/MdcAspectFactory.java @@ -1,4 +1,4 @@ -package io.koraframework.logging.aspect.mdc; +package io.koraframework.logging.annotation.processor.aop; import io.koraframework.aop.annotation.processor.KoraAspect; import io.koraframework.aop.annotation.processor.KoraAspectFactory; diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectClassNames.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectClassNames.java deleted file mode 100644 index 320f618d6..000000000 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/aspect/LogAspectClassNames.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.koraframework.logging.aspect; - -import com.palantir.javapoet.ClassName; - -public class LogAspectClassNames { - public static final ClassName log = ClassName.get("io.koraframework.logging.common.annotation", "Log"); - public static final ClassName logIn = log.nestedClass("in"); - public static final ClassName logOut = log.nestedClass("out"); - public static final ClassName logOff = log.nestedClass("off"); - public static final ClassName logResult = log.nestedClass("result"); - public static final ClassName structuredArgument = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgument"); - public static final ClassName structuredArgumentMapper = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgumentMapper"); - - public static final ClassName loggerFactory = ClassName.get("org.slf4j", "ILoggerFactory"); - public static final ClassName logger = ClassName.get("org.slf4j", "Logger"); -} diff --git a/logging/logging-annotation-processor/src/main/resources/META-INF/services/io.koraframework.aop.annotation.processor.KoraAspectFactory b/logging/logging-annotation-processor/src/main/resources/META-INF/services/io.koraframework.aop.annotation.processor.KoraAspectFactory index 7bcc29fc1..138577e42 100644 --- a/logging/logging-annotation-processor/src/main/resources/META-INF/services/io.koraframework.aop.annotation.processor.KoraAspectFactory +++ b/logging/logging-annotation-processor/src/main/resources/META-INF/services/io.koraframework.aop.annotation.processor.KoraAspectFactory @@ -1,2 +1,2 @@ -io.koraframework.logging.aspect.LogAspectFactory -io.koraframework.logging.aspect.mdc.MdcAspectFactory +io.koraframework.logging.annotation.processor.aop.LogAspectFactory +io.koraframework.logging.annotation.processor.aop.MdcAspectFactory diff --git a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java index 067712ef5..d569e2865 100644 --- a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java +++ b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java @@ -40,6 +40,9 @@ void setUp() { protected String commonImports() { return super.commonImports() + """ import io.koraframework.logging.common.annotation.Log; + import io.koraframework.logging.common.annotation.Mask; + import io.koraframework.json.common.annotation.Json; + import io.koraframework.json.common.annotation.JsonField; import static org.slf4j.event.Level.*; """; } diff --git a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java index 3369a1b42..9ccffd2c0 100644 --- a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java +++ b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java @@ -3,6 +3,16 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import io.koraframework.aop.annotation.processor.AopAnnotationProcessor; +import io.koraframework.json.annotation.processor.JsonAnnotationProcessor; +import io.koraframework.json.common.JsonWriter; +import io.koraframework.kora.app.annotation.processor.KoraAppProcessor; +import io.koraframework.logging.annotation.processor.LoggingAnnotationProcessor; +import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper; +import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; +import io.koraframework.logging.common.masking.MaskingMetadata; +import io.koraframework.logging.common.arg.StructuredArgumentWriter; +import io.koraframework.json.common.writer.ListJsonWriter; +import io.koraframework.json.common.writer.MapJsonWriter; import java.util.List; import java.util.Map; @@ -413,4 +423,240 @@ public void write(JsonGenerator gen, T value) { o.verifyNoMoreInteractions(); } + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testLogArgsWithJsonMapperTag() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Json + public record TestRecord(String value) {} + """, """ + public class Target { + @Log.in + public void test(@Json TestRecord arg1) {} + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$TestRecord_JsonWriter"); + var mapper = new JsonStructuredArgumentMapper<>(writer); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test", newObject("TestRecord", "test-value")); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(inData.capture(), eq(">")); + o.verifyNoMoreInteractions(); + verifyInJson("{\"arg1\":{\"value\":\"test-value\"}}"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testLogArgsWithMaskedMapperTag() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask(mode = Mask.Mode.KEEP_FIRST, keep = 1) + @Json + public record Credentials(@JsonField("secret") @Mask String secret, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) String token, String login) {} + """, """ + @Mask + @Json + public record User(String name, Credentials credentials) {} + """, """ + public class Target { + @Log.in + public void test(@Mask User arg1) {} + } + """); + compileResult.assertSuccess(); + var credentialsWriter = (JsonWriter) newObject("$Credentials_JsonWriter"); + var userWriter = (JsonWriter) newObject("$User_JsonWriter", credentialsWriter); + var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); + var mapper = new MaskedStructuredArgumentMapper<>(userWriter, metadata); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + var credentials = newObject("Credentials", "secret", "token", "login"); + var user = newObject("User", "user", credentials); + reset(log, DEBUG); + aopProxy.invoke("test", user); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(inData.capture(), eq(">")); + o.verifyNoMoreInteractions(); + verifyInJson("{\"arg1\":{\"name\":\"user\",\"credentials\":{\"secret\":\"s***\",\"token\":\"***en\",\"login\":\"login\"}}}"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testLogResultWithJsonMapperTag() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Json + public record TestRecord(String value) {} + """, """ + public class Target { + @Log.out + @Json + public TestRecord test() { + return new TestRecord("test-value"); + } + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$TestRecord_JsonWriter"); + var mapper = new JsonStructuredArgumentMapper<>(writer); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test"); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(outData.capture(), eq("<")); + o.verifyNoMoreInteractions(); + verifyOutJson("{\"out\":{\"value\":\"test-value\"}}"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testLogResultWithMaskedMapperTag() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask + @Json + public record User(String name, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) String token) {} + """, """ + public class Target { + @Log.out + @Mask + public User test() { + return new User("user", "secret"); + } + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$User_JsonWriter"); + var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); + var mapper = new MaskedStructuredArgumentMapper<>(writer, metadata); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test"); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(outData.capture(), eq("<")); + o.verifyNoMoreInteractions(); + verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"***et\"}}"); + } + + @Test + public void testMaskingMetadataSupportsRecursiveType() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor()), """ + @Mask + @Json + public record User(String name, @Mask String token, User manager) {} + """); + compileResult.assertSuccess(); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testMaskingMetadataSupportsNestedGenericContainers() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask + @Json + public record Credentials(@Mask String secret) {} + """, """ + @Mask + @Json + public record User(java.util.List> nestedList, java.util.Map> nestedMap) {} + """, """ + public class Target { + @Log.in + public void test(@Mask User arg1) {} + } + """); + compileResult.assertSuccess(); + var credentialsWriter = (JsonWriter) newObject("$Credentials_JsonWriter"); + var listWriter = new ListJsonWriter<>(credentialsWriter); + var nestedListWriter = new ListJsonWriter<>(listWriter); + var nestedMapWriter = new MapJsonWriter<>(listWriter); + var userWriter = (JsonWriter) newObject("$User_JsonWriter", nestedListWriter, nestedMapWriter); + var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); + var mapper = new MaskedStructuredArgumentMapper<>(userWriter, metadata); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + var credentials1 = newObject("Credentials", "secret-1"); + var credentials2 = newObject("Credentials", "secret-2"); + var user = newObject("User", List.of(List.of(credentials1)), Map.of("key", List.of(credentials2))); + reset(log, DEBUG); + aopProxy.invoke("test", user); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(inData.capture(), eq(">")); + o.verifyNoMoreInteractions(); + verifyInJson("{\"arg1\":{\"nestedList\":[[{\"secret\":\"***\"}]],\"nestedMap\":{\"key\":[{\"secret\":\"***\"}]}}}"); + } + + @Test + public void testMaskingMetadataComponentIsResolvedByGraph() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new KoraAppProcessor()), """ + @Mask + @Json + public record User(@Mask String password) {} + """, """ + @KoraApp + public interface TestApp extends io.koraframework.json.common.JsonModule { + @Tag(Mask.class) + default io.koraframework.logging.common.arg.StructuredArgumentMapper maskedStructuredArgumentMapper( + io.koraframework.json.common.JsonWriter writer, + io.koraframework.logging.common.masking.MaskingMetadata metadata + ) { + return new io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper<>(writer, metadata); + } + + @Root + default Object root(@Tag(Mask.class) io.koraframework.logging.common.arg.StructuredArgumentMapper mapper) { + return mapper; + } + } + """); + compileResult.assertSuccess(); + } + + private void verifyInJson(String expectedJson) { + var writer = (StructuredArgumentWriter) inData.getValue(); + org.assertj.core.api.Assertions.assertThat(writer.writeToString()).isEqualTo(expectedJson); + } + + private void verifyOutJson(String expectedJson) { + var writer = (StructuredArgumentWriter) outData.getValue(); + org.assertj.core.api.Assertions.assertThat(writer.writeToString()).isEqualTo(expectedJson); + } + } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java index 21f2779bd..9c7b271e0 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java @@ -2,8 +2,16 @@ import io.koraframework.common.annotation.DefaultComponent; import io.koraframework.common.annotation.Root; +import io.koraframework.common.annotation.Tag; import io.koraframework.config.common.Config; import io.koraframework.config.common.mapper.ConfigValueMapper; +import io.koraframework.json.common.JsonWriter; +import io.koraframework.json.common.annotation.Json; +import io.koraframework.logging.common.annotation.Mask; +import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper; +import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; +import io.koraframework.logging.common.arg.StructuredArgumentMapper; +import io.koraframework.logging.common.masking.MaskingMetadata; import org.slf4j.ILoggerFactory; import org.slf4j.LoggerFactory; @@ -22,5 +30,16 @@ default LoggingConfig loggingConfig(Config config, ConfigValueMapper StructuredArgumentMapper jsonStructuredArgumentMapper(JsonWriter writer) { + return new JsonStructuredArgumentMapper<>(writer); + } + + @Tag(Mask.class) + @DefaultComponent + default StructuredArgumentMapper maskedStructuredArgumentMapper(JsonWriter writer, MaskingMetadata metadata) { + return new MaskedStructuredArgumentMapper<>(writer, metadata); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java new file mode 100644 index 000000000..0036084d7 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java @@ -0,0 +1,23 @@ +package io.koraframework.logging.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD, ElementType.RECORD_COMPONENT, ElementType.METHOD, ElementType.TYPE_USE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Mask { + + String value() default "***"; + + Mode mode() default Mode.FULL; + + int keep() default 4; + + enum Mode { + FULL, + KEEP_LAST, + KEEP_FIRST + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/JsonStructuredArgumentMapper.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/JsonStructuredArgumentMapper.java new file mode 100644 index 000000000..bcdc00bb0 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/JsonStructuredArgumentMapper.java @@ -0,0 +1,17 @@ +package io.koraframework.logging.common.arg; + +import io.koraframework.json.common.JsonWriter; +import tools.jackson.core.JsonGenerator; + +public final class JsonStructuredArgumentMapper implements StructuredArgumentMapper { + private final JsonWriter writer; + + public JsonStructuredArgumentMapper(JsonWriter writer) { + this.writer = writer; + } + + @Override + public void write(JsonGenerator gen, T value) { + this.writer.write(gen, value); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java new file mode 100644 index 000000000..8c53d043f --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java @@ -0,0 +1,21 @@ +package io.koraframework.logging.common.arg; + +import io.koraframework.json.common.JsonWriter; +import io.koraframework.logging.common.masking.MaskingJsonGenerator; +import io.koraframework.logging.common.masking.MaskingMetadata; +import tools.jackson.core.JsonGenerator; + +public final class MaskedStructuredArgumentMapper implements StructuredArgumentMapper { + private final JsonWriter writer; + private final MaskingMetadata metadata; + + public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingMetadata metadata) { + this.writer = writer; + this.metadata = metadata; + } + + @Override + public void write(JsonGenerator gen, T value) { + this.writer.write(new MaskingJsonGenerator(gen, this.metadata), value); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java new file mode 100644 index 000000000..9319ba015 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java @@ -0,0 +1,39 @@ +package io.koraframework.logging.common.masking; + +import io.koraframework.logging.common.annotation.Mask; +import org.jspecify.annotations.Nullable; + +public record MaskRule(String replacement, Mask.Mode mode, int keep) { + + public static MaskRule replacement(String replacement, Mask.Mode mode, int keep) { + return new MaskRule(replacement, mode, keep); + } + + public String apply(@Nullable Object value) { + if (this.mode == Mask.Mode.FULL || value == null) { + return this.replacement; + } + + var stringValue = value.toString(); + return switch (this.mode) { + case FULL -> this.replacement; + case KEEP_LAST -> this.keepLast(stringValue); + case KEEP_FIRST -> this.keepFirst(stringValue); + }; + } + + private String keepLast(String value) { + if (value.length() <= this.keep) { + return this.replacement; + } + return this.replacement + value.substring(value.length() - this.keep); + } + + private String keepFirst(String value) { + if (value.length() <= this.keep) { + return this.replacement; + } + return value.substring(0, this.keep) + this.replacement; + } + +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java new file mode 100644 index 000000000..e7258c1e6 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java @@ -0,0 +1,12 @@ +package io.koraframework.logging.common.masking; + +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +public record MaskingClassMeta(Map fields) { + @Nullable + public MaskingFieldMeta field(String name) { + return this.fields.get(name); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java new file mode 100644 index 000000000..cb929e173 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java @@ -0,0 +1,28 @@ +package io.koraframework.logging.common.masking; + +import org.jspecify.annotations.Nullable; + +public record MaskingFieldMeta(Kind kind, @Nullable Class type, @Nullable MaskRule rule) { + public enum Kind { + MASK, + OBJECT, + COLLECTION, + MAP_VALUE + } + + public static MaskingFieldMeta mask(MaskRule rule) { + return new MaskingFieldMeta(Kind.MASK, null, rule); + } + + public static MaskingFieldMeta object(Class type) { + return new MaskingFieldMeta(Kind.OBJECT, type, null); + } + + public static MaskingFieldMeta collection(Class type) { + return new MaskingFieldMeta(Kind.COLLECTION, type, null); + } + + public static MaskingFieldMeta mapValue(Class type) { + return new MaskingFieldMeta(Kind.MAP_VALUE, type, null); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java new file mode 100644 index 000000000..13a838acf --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java @@ -0,0 +1,318 @@ +package io.koraframework.logging.common.masking; + +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.SerializableString; +import tools.jackson.core.util.JsonGeneratorDelegate; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + +public final class MaskingJsonGenerator extends JsonGeneratorDelegate { + private final MaskingMetadata metadata; + private final Deque stack = new ArrayDeque<>(); + private int suppressDepth; + + public MaskingJsonGenerator(JsonGenerator delegate, MaskingMetadata metadata) { + super(delegate); + this.metadata = metadata; + } + + @Override + public JsonGenerator writeStartObject(Object forValue) throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth++; + return this; + } + var pending = this.pending(); + if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { + this.writeMask(pending, null); + this.clearPending(); + this.suppressDepth = 1; + return this; + } + this.clearPending(); + super.writeStartObject(forValue); + this.stack.push(new Context(this.findMeta(forValue), null, null)); + return this; + } + + @Override + public JsonGenerator writeStartObject() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth++; + return this; + } + var pending = this.pending(); + if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { + this.writeMask(pending, null); + this.clearPending(); + this.suppressDepth = 1; + return this; + } + var meta = this.findMeta(this.nextObjectType()); + this.clearPending(); + super.writeStartObject(); + this.stack.push(new Context(meta, null, null)); + return this; + } + + @Override + public JsonGenerator writeEndObject() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth--; + return this; + } + super.writeEndObject(); + if (!this.stack.isEmpty()) { + this.stack.pop(); + } + return this; + } + + @Override + public JsonGenerator writeStartArray() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth++; + return this; + } + var pending = this.pending(); + if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { + this.writeMask(pending, null); + this.clearPending(); + this.suppressDepth = 1; + return this; + } + var elementType = pending != null + && (pending.kind() == MaskingFieldMeta.Kind.COLLECTION || pending.kind() == MaskingFieldMeta.Kind.MAP_VALUE) + ? pending.type() + : null; + this.clearPending(); + super.writeStartArray(); + this.stack.push(new Context(null, null, elementType)); + return this; + } + + @Override + public JsonGenerator writeStartArray(Object forValue) throws JacksonException { + return this.writeStartArray(); + } + + @Override + public JsonGenerator writeStartArray(Object forValue, int size) throws JacksonException { + return this.writeStartArray(); + } + + @Override + public JsonGenerator writeEndArray() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth--; + return this; + } + super.writeEndArray(); + if (!this.stack.isEmpty()) { + this.stack.pop(); + } + return this; + } + + @Override + public JsonGenerator writeName(String name) throws JacksonException { + if (this.suppressDepth > 0) { + return this; + } + super.writeName(name); + if (!this.stack.isEmpty() && this.stack.peek().classMeta() != null) { + this.stack.peek().pending = this.stack.peek().classMeta().field(name); + } + return this; + } + + @Override + public JsonGenerator writeName(SerializableString name) throws JacksonException { + return this.writeName(name.getValue()); + } + + @Override + public JsonGenerator writeString(String text) throws JacksonException { + if (this.skipOrMaskScalar(text)) { + return this; + } + return super.writeString(text); + } + + @Override + public JsonGenerator writeString(char[] text, int offset, int len) throws JacksonException { + if (this.skipOrMaskScalar(new String(Arrays.copyOfRange(text, offset, offset + len)))) { + return this; + } + return super.writeString(text, offset, len); + } + + @Override + public JsonGenerator writeString(SerializableString text) throws JacksonException { + if (this.skipOrMaskScalar(text.getValue())) { + return this; + } + return super.writeString(text); + } + + @Override + public JsonGenerator writeNumber(short v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(int v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(long v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(BigInteger v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(double v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(float v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(BigDecimal v) throws JacksonException { + if (this.skipOrMaskScalar(v)) { + return this; + } + return super.writeNumber(v); + } + + @Override + public JsonGenerator writeNumber(String encodedValue) throws JacksonException { + if (this.skipOrMaskScalar(encodedValue)) { + return this; + } + return super.writeNumber(encodedValue); + } + + @Override + public JsonGenerator writeBoolean(boolean state) throws JacksonException { + if (this.skipOrMaskScalar(state)) { + return this; + } + return super.writeBoolean(state); + } + + @Override + public JsonGenerator writeNull() throws JacksonException { + if (this.skipOrMaskScalar(null)) { + return this; + } + return super.writeNull(); + } + + private boolean skipOrMaskScalar(@Nullable Object value) throws JacksonException { + if (this.suppressDepth > 0) { + return true; + } + var pending = this.pending(); + if (pending != null) { + if (pending.kind() == MaskingFieldMeta.Kind.MASK) { + this.writeMask(pending, value); + } + this.clearPending(); + return pending.kind() == MaskingFieldMeta.Kind.MASK; + } + return false; + } + + private void writeMask(MaskingFieldMeta fieldMeta, @Nullable Object value) throws JacksonException { + var rule = fieldMeta.rule(); + super.writeString(rule == null ? "***" : rule.apply(value)); + } + + @Nullable + private MaskingFieldMeta pending() { + return this.stack.isEmpty() ? null : this.stack.peek().pending; + } + + private void clearPending() { + if (!this.stack.isEmpty()) { + this.stack.peek().pending = null; + } + } + + @Nullable + private Class nextObjectType() { + var pending = this.pending(); + if (pending != null && pending.type() != null) { + return pending.type(); + } + if (!this.stack.isEmpty()) { + return this.stack.peek().arrayElementType(); + } + return null; + } + + @Nullable + private MaskingClassMeta findMeta(@Nullable Object value) { + return value == null ? null : this.metadata.metadata(value.getClass()); + } + + @Nullable + private MaskingClassMeta findMeta(@Nullable Class type) { + return type == null ? null : this.metadata.metadata(type); + } + + private static final class Context { + private final @Nullable MaskingClassMeta classMeta; + private @Nullable MaskingFieldMeta pending; + private final @Nullable Class arrayElementType; + + private Context(@Nullable MaskingClassMeta classMeta, @Nullable MaskingFieldMeta pending, @Nullable Class arrayElementType) { + this.classMeta = classMeta; + this.pending = pending; + this.arrayElementType = arrayElementType; + } + + @Nullable + private MaskingClassMeta classMeta() { + return this.classMeta; + } + + @Nullable + private Class arrayElementType() { + return this.arrayElementType; + } + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java new file mode 100644 index 000000000..a31d132e5 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java @@ -0,0 +1,8 @@ +package io.koraframework.logging.common.masking; + +import org.jspecify.annotations.Nullable; + +public interface MaskingMetadata { + @Nullable + MaskingClassMeta metadata(Class type); +} diff --git a/logging/logging-common/src/main/java/module-info.java b/logging/logging-common/src/main/java/module-info.java index 758ab54f1..ac668492b 100644 --- a/logging/logging-common/src/main/java/module-info.java +++ b/logging/logging-common/src/main/java/module-info.java @@ -11,4 +11,5 @@ exports io.koraframework.logging.common; exports io.koraframework.logging.common.annotation; exports io.koraframework.logging.common.arg; + exports io.koraframework.logging.common.masking; } diff --git a/logging/logging-symbol-processor/build.gradle b/logging/logging-symbol-processor/build.gradle index f3b3bb059..3d24fc15b 100644 --- a/logging/logging-symbol-processor/build.gradle +++ b/logging/logging-symbol-processor/build.gradle @@ -4,6 +4,9 @@ dependencies { implementation project(":core:symbol-processor-common") implementation project(':aop:aop-symbol-processor') + testImplementation project(':json:json-common') + testImplementation project(':json:json-symbol-processor') + testImplementation project(':logging:logging-common') testImplementation project(':logging:logging-logback') testImplementation testFixtures(project(":core:symbol-processor-common")) diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt new file mode 100644 index 000000000..c87310df6 --- /dev/null +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt @@ -0,0 +1,250 @@ +package io.koraframework.logging.symbol.processor + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.writeTo +import io.koraframework.ksp.common.AnnotationUtils.findAnnotation +import io.koraframework.ksp.common.AnnotationUtils.findValue +import io.koraframework.ksp.common.AnnotationUtils.findValueNoDefault +import io.koraframework.ksp.common.AnnotationUtils.isAnnotationPresent +import io.koraframework.ksp.common.BaseSymbolProcessor +import io.koraframework.ksp.common.CommonClassNames +import io.koraframework.ksp.common.CommonClassNames.isCollection +import io.koraframework.ksp.common.CommonClassNames.isMap +import io.koraframework.ksp.common.KspCommonUtils.addOriginatingKSFile +import io.koraframework.ksp.common.KspCommonUtils.generated +import io.koraframework.ksp.common.KspCommonUtils.getNameConverter +import io.koraframework.ksp.common.generatedClassName + +class MaskingMetadataSymbolProcessor( + environment: SymbolProcessorEnvironment +) : BaseSymbolProcessor(environment) { + + private val processed = HashSet() + private val codeGenerator: CodeGenerator = environment.codeGenerator + + override fun processRound(resolver: Resolver): List { + val delayed = ArrayList() + for (symbol in resolver.getSymbolsWithAnnotation(LoggingTypes.mask.canonicalName)) { + if (!symbol.validateAll()) { + delayed.add(symbol) + continue + } + if (symbol !is KSClassDeclaration) { + continue + } + if (symbol.modifiers.contains(Modifier.ABSTRACT)) { + kspLogger.error("Abstract classes can't be annotated with @Mask", symbol) + continue + } + if (!processed.add(symbol.qualifiedName!!.asString())) { + continue + } + MaskingMetadataGenerator(resolver, codeGenerator).generate(symbol) + } + return delayed + } +} + +private class MaskingMetadataGenerator( + private val resolver: Resolver, + private val codeGenerator: CodeGenerator +) { + fun generate(root: KSClassDeclaration) { + val packageName = root.packageName.asString() + val className = root.generatedClassName("MaskingMetadata") + if (resolver.getClassDeclarationByName(resolver.getKSNameFromString("$packageName.$className")) != null) { + return + } + + val visited = linkedMapOf() + visit(root, visited) + + val metadataType = LoggingTypes.maskingMetadata.parameterizedBy(root.toClassName()) + val type = TypeSpec.classBuilder(className) + .addModifiers(KModifier.PUBLIC) + .addAnnotation(CommonClassNames.component) + .generated(MaskingMetadataGenerator::class) + .addOriginatingKSFile(root) + .addSuperinterface(metadataType) + .primaryConstructor(FunSpec.constructorBuilder().build()) + .addProperty( + PropertySpec.builder( + "metadata", + Map::class.asClassName().parameterizedBy(Class::class.asClassName().parameterizedBy(STAR), LoggingTypes.maskingClassMeta), + KModifier.PRIVATE + ).initializer(metadataCode(visited.values)).build() + ) + .addFunction( + FunSpec.builder("metadata") + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .addParameter("type", Class::class.asClassName().parameterizedBy(STAR)) + .returns(LoggingTypes.maskingClassMeta.copy(nullable = true)) + .addStatement("return this.metadata[type]") + .build() + ) + .build() + + FileSpec.builder(packageName, className) + .addType(type) + .build() + .writeTo(codeGenerator = codeGenerator, aggregating = false) + } + + private fun metadataCode(metas: Collection): CodeBlock { + val keyType = Class::class.asClassName().parameterizedBy(STAR) + if (metas.isEmpty()) { + return CodeBlock.of("java.util.Map.of<%T, %T>()", keyType, LoggingTypes.maskingClassMeta) + } + val code = CodeBlock.builder().add("java.util.Map.ofEntries<%T, %T>(\n", keyType, LoggingTypes.maskingClassMeta).indent() + val iterator = metas.iterator() + while (iterator.hasNext()) { + val meta = iterator.next() + code.add("java.util.Map.entry<%T, %T>(%T::class.java, %T(%L))", keyType, LoggingTypes.maskingClassMeta, meta.type.toClassName(), LoggingTypes.maskingClassMeta, fieldsCode(meta)) + if (iterator.hasNext()) { + code.add(",\n") + } + } + return code.unindent().add("\n)").build() + } + + private fun fieldsCode(meta: MaskingClassMeta): CodeBlock { + val fields = ArrayList() + val typeMask = meta.type.findAnnotation(LoggingTypes.mask) + for (field in meta.fields) { + val mask = field.mask + if (mask != null) { + fields.add(CodeBlock.of("%S, %T.mask(%L)", field.jsonName, LoggingTypes.maskingFieldMeta, maskRuleCode(mask, typeMask))) + continue + } + val nestedType = nestedType(field.type) ?: continue + val code = when { + field.type.isCollection() -> CodeBlock.of("%S, %T.collection(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) + field.type.isMap() -> CodeBlock.of("%S, %T.mapValue(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) + else -> CodeBlock.of("%S, %T.object(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) + } + fields.add(code) + } + if (fields.isEmpty()) { + return CodeBlock.of("java.util.Map.of<%T, %T>()", String::class.asClassName(), LoggingTypes.maskingFieldMeta) + } + + val code = CodeBlock.builder().add("java.util.Map.ofEntries<%T, %T>(\n", String::class.asClassName(), LoggingTypes.maskingFieldMeta).indent() + for (i in fields.indices) { + code.add("java.util.Map.entry<%T, %T>(%L)", String::class.asClassName(), LoggingTypes.maskingFieldMeta, fields[i]) + if (i < fields.size - 1) { + code.add(",\n") + } + } + return code.unindent().add("\n)").build() + } + + private fun maskRuleCode(mask: KSAnnotation, typeMask: KSAnnotation?): CodeBlock { + val value = mask.findValueNoDefault("value") + ?: typeMask?.findValue("value") + ?: "***" + val mode = mask.findValueNoDefault("mode") + ?: typeMask?.findValue("mode") + val modeName = mode?.simpleName?.asString() ?: "FULL" + val keep = mask.findValueNoDefault("keep") + ?: typeMask?.findValue("keep") + ?: 4 + return CodeBlock.of("%T.replacement(%S, %T.%L, %L)", LoggingTypes.maskRule, value, LoggingTypes.maskMode, modeName, keep) + } + + private fun visit(type: KSClassDeclaration, visited: MutableMap) { + val key = type.qualifiedName!!.asString() + if (visited.containsKey(key) || !type.isJsonOrMasked()) { + return + } + val meta = parse(type) + visited[key] = meta + for (field in meta.fields) { + if (field.mask != null) { + continue + } + for (nested in nestedTypes(field.type)) { + visit(nested, visited) + } + } + } + + private fun parse(type: KSClassDeclaration): MaskingClassMeta { + val nameConverter = type.getNameConverter() + val fields = ArrayList() + for (property in type.getAllProperties()) { + if (property.isAnnotationPresent(LoggingTypes.jsonSkip)) { + continue + } + val constructorParameter = type.primaryConstructor?.parameters?.firstOrNull { it.name?.asString() == property.simpleName.asString() } + val jsonField = property.findAnnotation(LoggingTypes.jsonField) ?: constructorParameter?.findAnnotation(LoggingTypes.jsonField) + val jsonName = jsonField?.findValueNoDefault("value")?.takeIf { it.isNotBlank() } + ?: nameConverter?.convert(property.simpleName.asString()) + ?: property.simpleName.asString() + val mask = property.findAnnotation(LoggingTypes.mask) ?: constructorParameter?.findAnnotation(LoggingTypes.mask) + fields.add(MaskingField(jsonName, property.type.resolve(), mask)) + } + return MaskingClassMeta(type, fields) + } + + private fun nestedType(type: KSType): KSClassDeclaration? { + val nested = nestedTypes(type) + return if (nested.size == 1) nested[0] else null + } + + private fun nestedTypes(type: KSType): List { + if (type.isCollection()) { + val argument = type.arguments.firstOrNull()?.type?.resolve() ?: return emptyList() + return nestedTypes(argument) + } + if (type.isMap()) { + val argument = type.arguments.getOrNull(1)?.type?.resolve() ?: return emptyList() + return nestedTypes(argument) + } + val declaration = type.declaration + if (declaration !is KSClassDeclaration || !declaration.isJsonOrMasked()) { + return emptyList() + } + return listOf(declaration) + } + + private fun KSClassDeclaration.isJsonOrMasked(): Boolean { + return this.isAnnotationPresent(LoggingTypes.json) + || this.isAnnotationPresent(LoggingTypes.jsonWriter) + || this.isAnnotationPresent(LoggingTypes.mask) + } +} + +private object LoggingTypes { + val mask = ClassName("io.koraframework.logging.common.annotation", "Mask") + val maskMode = mask.nestedClass("Mode") + val json = ClassName("io.koraframework.json.common.annotation", "Json") + val jsonWriter = ClassName("io.koraframework.json.common.annotation", "JsonWriter") + val jsonField = ClassName("io.koraframework.json.common.annotation", "JsonField") + val jsonSkip = ClassName("io.koraframework.json.common.annotation", "JsonSkip") + val maskingMetadata = ClassName("io.koraframework.logging.common.masking", "MaskingMetadata") + val maskingClassMeta = ClassName("io.koraframework.logging.common.masking", "MaskingClassMeta") + val maskingFieldMeta = ClassName("io.koraframework.logging.common.masking", "MaskingFieldMeta") + val maskRule = ClassName("io.koraframework.logging.common.masking", "MaskRule") +} + +private data class MaskingClassMeta(val type: KSClassDeclaration, val fields: List) + +private data class MaskingField(val jsonName: String, val type: KSType, val mask: KSAnnotation?) diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt new file mode 100644 index 000000000..578c5258b --- /dev/null +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt @@ -0,0 +1,8 @@ +package io.koraframework.logging.symbol.processor + +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +class MaskingMetadataSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment) = MaskingMetadataSymbolProcessor(environment) +} diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt index e63dee465..2517bed98 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt @@ -18,6 +18,7 @@ import io.koraframework.ksp.common.FunctionUtils.isFlowVoid import io.koraframework.ksp.common.FunctionUtils.isVoid import io.koraframework.ksp.common.KotlinPoetUtils.controlFlow import io.koraframework.ksp.common.KotlinPoetUtils.nextControlFlow +import io.koraframework.ksp.common.MappingData import io.koraframework.ksp.common.parseMappingData class LogKoraAspect : KoraAspect { @@ -42,6 +43,7 @@ class LogKoraAspect : KoraAspect { val logOutAnnotation = logAnnotation.nestedClass("out") val logOffAnnotation = logAnnotation.nestedClass("off") val logResultAnnotation = logAnnotation.nestedClass("result") + val maskAnnotation = ClassName("io.koraframework.logging.common.annotation", "Mask") val structuredArgument = ClassName("io.koraframework.logging.common.arg", "StructuredArgument") val structuredArgumentMapper = ClassName("io.koraframework.logging.common.arg", "StructuredArgumentMapper") val iLoggerFactoryType = ClassName("org.slf4j", "ILoggerFactory") @@ -115,22 +117,22 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = parameter.structuredArgumentMapping() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } else { controlFlow("if (%N.%N())", loggerName, level.isEnabledMethod()) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = parameter.structuredArgumentMapping() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } @@ -169,11 +171,11 @@ class LogKoraAspect : KoraAspect { controlFlow("if (%N.%N())", loggerName, resultLogLevel.isEnabledMethod()) { controlFlow("val %L = %T.marker(%S) { gen -> ", DATA_OUT_FIELD_NAME, structuredArgument, DATA_PARAMETER_NAME) { addStatement("gen.writeStartObject()") - val mapping = function.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = function.structuredArgumentMapping() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) mapping.parameterized(function.returnType!!.resolve().toTypeName()) else it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(function.returnType!!.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, OUT_PARAMETER_NAME, RESULT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -240,22 +242,22 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = parameter.structuredArgumentMapping() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } else { controlFlow("if (%N.%N())", loggerName, level.isEnabledMethod()) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = parameter.structuredArgumentMapping() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } @@ -309,7 +311,7 @@ class LogKoraAspect : KoraAspect { controlFlow("%L = %L.%M { %L -> ", RESULT_FIELD_NAME, RESULT_FIELD_NAME, MemberName("kotlinx.coroutines.flow", "onEach"), ELEMENT_FIELD_NAME) { controlFlow("val %L = %T.marker(%S) { gen -> ", DATA_OUT_FIELD_NAME, structuredArgument, DATA_PARAMETER_NAME) { addStatement("gen.writeStartObject()") - val mapping = function.parseMappingData().getMapping(structuredArgumentMapper) + val mapping = function.structuredArgumentMapping() val flowGeneric = function.returnType!!.resolve().arguments[0].type!!.resolve() val mapperType = mapping?.mapper?.let { if (mapping.isGeneric()) @@ -318,7 +320,7 @@ class LogKoraAspect : KoraAspect { it.toTypeName() } ?: structuredArgumentMapper.parameterizedBy(flowGeneric.toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOf()) + val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) writeWithMapper(mapper, OUT_PARAMETER_NAME, ELEMENT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -363,6 +365,17 @@ class LogKoraAspect : KoraAspect { ?.let { Level.valueOf(it) } } + private fun KSAnnotated.structuredArgumentMapping(): MappingData? { + val mapping = this.parseMappingData().getMapping(structuredArgumentMapper) + if (mapping != null) { + return mapping + } + if (this.isAnnotationPresent(maskAnnotation)) { + return MappingData(null, maskAnnotation.canonicalName) + } + return null + } + private fun KSFunctionDeclaration.inLogLevel(): Level? { return this.parseLogLevel(logAnnotation) ?: this.parseLogLevel(logInAnnotation) diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspect.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspect.kt similarity index 99% rename from logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspect.kt rename to logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspect.kt index 0869f1819..69e86b003 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspect.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspect.kt @@ -1,4 +1,4 @@ -package io.koraframework.logging.symbol.processor.aop.mdc +package io.koraframework.logging.symbol.processor.aop import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSFunctionDeclaration diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspectFactory.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspectFactory.kt similarity index 83% rename from logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspectFactory.kt rename to logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspectFactory.kt index 002ff2d35..bdbac32a3 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/mdc/MdcKoraAspectFactory.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MdcKoraAspectFactory.kt @@ -1,4 +1,4 @@ -package io.koraframework.logging.symbol.processor.aop.mdc +package io.koraframework.logging.symbol.processor.aop import com.google.devtools.ksp.processing.Resolver import io.koraframework.aop.symbol.processor.KoraAspect diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodData.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodData.kt similarity index 81% rename from logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodData.kt rename to logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodData.kt index abff18708..01faeeb76 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodData.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodData.kt @@ -1,4 +1,4 @@ -package io.koraframework.logging.symbol.processor.aop.data +package io.koraframework.logging.symbol.processor.aop import org.slf4j.event.Level diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodParameterData.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodParameterData.kt similarity index 65% rename from logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodParameterData.kt rename to logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodParameterData.kt index d54ca577d..ced89ba8c 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/data/MethodParameterData.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/MethodParameterData.kt @@ -1,4 +1,4 @@ -package io.koraframework.logging.symbol.processor.aop.data +package io.koraframework.logging.symbol.processor.aop import org.slf4j.event.Level diff --git a/logging/logging-symbol-processor/src/main/resources/META-INF/services/io.koraframework.aop.symbol.processor.KoraAspectFactory b/logging/logging-symbol-processor/src/main/resources/META-INF/services/io.koraframework.aop.symbol.processor.KoraAspectFactory index 5c2389ce3..fb2139873 100644 --- a/logging/logging-symbol-processor/src/main/resources/META-INF/services/io.koraframework.aop.symbol.processor.KoraAspectFactory +++ b/logging/logging-symbol-processor/src/main/resources/META-INF/services/io.koraframework.aop.symbol.processor.KoraAspectFactory @@ -1,2 +1,2 @@ io.koraframework.logging.symbol.processor.aop.LogKoraAspectFactory -io.koraframework.logging.symbol.processor.aop.mdc.MdcKoraAspectFactory +io.koraframework.logging.symbol.processor.aop.MdcKoraAspectFactory diff --git a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt index b1b2b9c16..426e4834b 100644 --- a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt +++ b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt @@ -36,6 +36,9 @@ abstract class AbstractLogAspectTest : AbstractSymbolProcessorTest() { override fun commonImports(): String { return super.commonImports() + """ import io.koraframework.logging.common.annotation.Log + import io.koraframework.logging.common.annotation.Mask + import io.koraframework.json.common.annotation.Json + import io.koraframework.json.common.annotation.JsonField import org.slf4j.event.Level.* import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -98,6 +101,16 @@ abstract class AbstractLogAspectTest : AbstractSymbolProcessorTest() { verifyData(outData, expectedData) } + protected open fun verifyInJson(expectedJson: String) { + val writer = inData.value as StructuredArgumentWriter + Assertions.assertThat(writer.writeToString()).isEqualTo(expectedJson) + } + + protected open fun verifyOutJson(expectedJson: String) { + val writer = outData.value as StructuredArgumentWriter + Assertions.assertThat(writer.writeToString()).isEqualTo(expectedJson) + } + protected open fun verifyData(captor: ArgumentCaptor, expectedData: Map) { Assertions.assertThat(captor.value) .isNotNull diff --git a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt index 8c431be68..9a03ea99e 100644 --- a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt +++ b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt @@ -6,6 +6,14 @@ import org.mockito.ArgumentMatchers import org.mockito.Mockito import org.slf4j.event.Level import io.koraframework.aop.symbol.processor.AopSymbolProcessorProvider +import io.koraframework.json.common.JsonWriter +import io.koraframework.json.common.writer.ListJsonWriter +import io.koraframework.json.common.writer.MapJsonWriter +import io.koraframework.json.ksp.JsonSymbolProcessorProvider +import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper +import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper +import io.koraframework.logging.common.masking.MaskingMetadata +import io.koraframework.logging.symbol.processor.MaskingMetadataSymbolProcessorProvider import java.util.* class LogAspectTest : AbstractLogAspectTest() { @@ -381,4 +389,125 @@ class LogAspectTest : AbstractLogAspectTest() { o.verifyNoMoreInteractions() } + @Test + fun testMaskingMetadataSupportsNestedGenericContainers() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Mask + @Json + data class Credentials(@Mask val secret: String) + """.trimIndent(), + """ + @Mask + @Json + data class User(val nestedList: List>, val nestedMap: Map>) + """.trimIndent(), + """ + open class Target { + @Log.`in` + open fun test(@Mask arg1: User) {} + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val credentialsWriter = new("\$Credentials_JsonWriter") as JsonWriter + val listWriter = ListJsonWriter(credentialsWriter) + val nestedListWriter = ListJsonWriter(listWriter) + val nestedMapWriter = MapJsonWriter(listWriter) + val userWriter = new("\$User_JsonWriter", nestedListWriter, nestedMapWriter) as JsonWriter + val metadata = new("\$User_MaskingMetadata") as MaskingMetadata + val mapper = MaskedStructuredArgumentMapper(userWriter, metadata) + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + val credentials1 = new("Credentials", "secret-1") + val credentials2 = new("Credentials", "secret-2") + val user = new("User", listOf(listOf(credentials1)), mapOf("key" to listOf(credentials2))) + reset(log, Level.DEBUG) + aopProxy.invoke("test", user) + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(inData.capture(), ArgumentMatchers.eq(">")) + o.verifyNoMoreInteractions() + verifyInJson("{\"arg1\":{\"nestedList\":[[{\"secret\":\"***\"}]],\"nestedMap\":{\"key\":[{\"secret\":\"***\"}]}}}") + } + + @Test + fun testLogResultWithJsonMapperTag() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Json + data class TestRecord(val value: String) + """.trimIndent(), + """ + open class Target { + @Log.out + @Json + open fun test(): TestRecord { + return TestRecord("test-value") + } + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$TestRecord_JsonWriter") as JsonWriter + val mapper = JsonStructuredArgumentMapper(writer) + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + reset(log, Level.DEBUG) + aopProxy.invoke("test") + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(outData.capture(), ArgumentMatchers.eq("<")) + o.verifyNoMoreInteractions() + verifyOutJson("{\"out\":{\"value\":\"test-value\"}}") + } + + @Test + fun testLogResultWithMaskedMapperTag() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Mask + @Json + data class User(val name: String, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) val token: String) + """.trimIndent(), + """ + open class Target { + @Log.out + @Mask + open fun test(): User { + return User("user", "secret") + } + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$User_JsonWriter") as JsonWriter + val metadata = new("\$User_MaskingMetadata") as MaskingMetadata + val mapper = MaskedStructuredArgumentMapper(writer, metadata) + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + reset(log, Level.DEBUG) + aopProxy.invoke("test") + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(outData.capture(), ArgumentMatchers.eq("<")) + o.verifyNoMoreInteractions() + verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"***et\"}}") + } + } From d8f237026cf6b0356fc51c55ad53b6f6ae9e9ba3 Mon Sep 17 00:00:00 2001 From: Anton Kurako Date: Thu, 23 Jul 2026 15:11:43 +0300 Subject: [PATCH 2/3] Added masked log argument rules with custom mapping and structured modes Added declarative masking rules for logged values, including generated default MaskingRules components and custom @Mapping-selected rules for @Mask parameters and results. - Added MaskingStrategy-based masking with built-in full, keep-first, and keep-last strategies. - Added recursive path-based MaskingRules generation for Java AP and KSP, including nested collections and map value wildcards. - Improved masked logging so @Mask writes masked JSON as a string by default, while @Mask @Json keeps structured JSON output. - Added support for custom MaskingRules implementations selected through @Mapping on logged arguments and result values. --- .../processor/LoggingAnnotationProcessor.java | 6 +- .../processor/MaskingMetadataProcessor.java | 315 ------------------ .../annotation/processor/aop/LogAspect.java | 104 ++++-- .../processor/aop/LogAspectClassNames.java | 8 +- .../logging/aspect/AbstractLogAspectTest.java | 6 + .../logging/aspect/LogAspectTest.java | 227 +++++++++++-- .../logging/common/LoggingModule.java | 33 +- .../logging/common/annotation/Mask.java | 15 +- .../arg/MaskedStructuredArgumentMapper.java | 34 +- .../common/arg/StructuredArgument.java | 1 + .../common/arg/StructuredArgumentMapper.java | 12 + .../common/arg/StructuredArgumentWriter.java | 1 + .../logging/common/masking/MaskRule.java | 39 --- .../common/masking/MaskingClassMeta.java | 12 - .../common/masking/MaskingFieldMeta.java | 28 -- .../logging/common/masking/MaskingFull.java | 20 ++ .../common/masking/MaskingJsonGenerator.java | 181 ++++------ .../common/masking/MaskingKeepFirst.java | 27 ++ .../common/masking/MaskingKeepLast.java | 27 ++ .../common/masking/MaskingMetadata.java | 8 - .../logging/common/masking/MaskingRules.java | 121 +++++++ .../common/masking/MaskingStrategy.java | 27 ++ .../MaskingMetadataSymbolProcessor.kt | 250 -------------- .../MaskingMetadataSymbolProcessorProvider.kt | 8 - .../symbol/processor/aop/LogKoraAspect.kt | 103 +++--- .../processor/aop/AbstractLogAspectTest.kt | 6 + .../symbol/processor/aop/LogAspectTest.kt | 167 +++++++++- 27 files changed, 870 insertions(+), 916 deletions(-) delete mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java delete mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java delete mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java delete mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFull.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepFirst.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepLast.java delete mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingRules.java create mode 100644 logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingStrategy.java delete mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt delete mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java index 2c85c1b26..a4533048e 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/LoggingAnnotationProcessor.java @@ -17,7 +17,7 @@ public final class LoggingAnnotationProcessor extends AbstractKoraProcessor { - private MaskingMetadataProcessor maskingMetadataProcessor; + private MaskingRulesProcessor maskingRulesProcessor; @Override public Set getSupportedAnnotationClassNames() { @@ -27,7 +27,7 @@ public Set getSupportedAnnotationClassNames() { @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); - this.maskingMetadataProcessor = new MaskingMetadataProcessor(processingEnv); + this.maskingRulesProcessor = new MaskingRulesProcessor(processingEnv); } @Override @@ -50,7 +50,7 @@ protected void process(Set annotations, RoundEnvironment continue; } try { - this.maskingMetadataProcessor.generate(typeElement); + this.maskingRulesProcessor.generate(typeElement); } catch (ProcessingErrorException e) { e.printError(this.processingEnv); } diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java deleted file mode 100644 index 7ad0ca5c8..000000000 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingMetadataProcessor.java +++ /dev/null @@ -1,315 +0,0 @@ -package io.koraframework.logging.annotation.processor; - -import com.palantir.javapoet.*; -import io.koraframework.annotation.processor.common.*; -import io.koraframework.logging.annotation.processor.aop.LogAspectClassNames; - -import javax.annotation.processing.ProcessingEnvironment; -import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.Element; -import javax.lang.model.element.ElementKind; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.RecordComponentElement; -import javax.lang.model.element.TypeElement; -import javax.lang.model.element.VariableElement; -import javax.lang.model.type.DeclaredType; -import javax.lang.model.type.TypeKind; -import javax.lang.model.type.TypeMirror; -import javax.lang.model.util.Elements; -import javax.lang.model.util.Types; -import java.util.*; - -public final class MaskingMetadataProcessor { - - private static final ClassName MAP = ClassName.get(Map.class); - private static final ClassName CLASS = ClassName.get(Class.class); - private static final ClassName MASK_MODE = LogAspectClassNames.mask.nestedClass("Mode"); - - private final ProcessingEnvironment env; - private final Elements elements; - private final Types types; - - public MaskingMetadataProcessor(ProcessingEnvironment env) { - this.env = env; - this.elements = env.getElementUtils(); - this.types = env.getTypeUtils(); - } - - public void generate(TypeElement root) { - var packageName = this.elements.getPackageOf(root).getQualifiedName().toString(); - var className = metadataName(root); - if (this.elements.getTypeElement(packageName + "." + className) != null) { - return; - } - - var visited = new LinkedHashMap(); - this.visit(root, visited); - - var rootType = TypeName.get(root.asType()); - var metadataType = ParameterizedTypeName.get(LogAspectClassNames.maskingMetadata, rootType); - var type = TypeSpec.classBuilder(className) - .addModifiers(javax.lang.model.element.Modifier.PUBLIC, javax.lang.model.element.Modifier.FINAL) - .addAnnotation(CommonClassNames.component) - .addAnnotation(AnnotationUtils.generated(MaskingMetadataProcessor.class)) - .addSuperinterface(metadataType) - .addField(FieldSpec.builder( - ParameterizedTypeName.get(MAP, ParameterizedTypeName.get(CLASS, WildcardTypeName.subtypeOf(Object.class)), LogAspectClassNames.maskingClassMeta), - "metadata", - javax.lang.model.element.Modifier.PRIVATE, - javax.lang.model.element.Modifier.FINAL - ).build()) - .addMethod(this.constructor(visited.values())) - .addMethod(this.metadataMethod()) - .build(); - - CommonUtils.safeWriteTo(this.env, JavaFile.builder(packageName, type).build()); - } - - private MethodSpec constructor(Collection metas) { - var method = MethodSpec.constructorBuilder() - .addModifiers(javax.lang.model.element.Modifier.PUBLIC); - method.addCode("this.metadata = $L;\n", this.metadataCode(metas)); - return method.build(); - } - - private CodeBlock metadataCode(Collection metas) { - if (metas.isEmpty()) { - return CodeBlock.of("$T.of()", MAP); - } - - var code = CodeBlock.builder().add("$T.ofEntries(\n$>", MAP); - var iterator = metas.iterator(); - while (iterator.hasNext()) { - var meta = iterator.next(); - code.add("$T.entry($T.class, new $T($L))", MAP, TypeName.get(meta.type().asType()), LogAspectClassNames.maskingClassMeta, this.fieldsCode(meta)); - if (iterator.hasNext()) { - code.add(",\n"); - } - } - return code.add("$<\n)").build(); - } - - private MethodSpec metadataMethod() { - return MethodSpec.methodBuilder("metadata") - .addAnnotation(Override.class) - .addModifiers(javax.lang.model.element.Modifier.PUBLIC) - .returns(LogAspectClassNames.maskingClassMeta) - .addParameter(ParameterizedTypeName.get(CLASS, WildcardTypeName.subtypeOf(Object.class)), "type") - .addStatement("return this.metadata.get(type)") - .build(); - } - - private CodeBlock fieldsCode(MaskingClassMeta meta) { - var fields = new ArrayList(); - var typeMask = findMask(meta.type()); - for (var field : meta.fields()) { - var mask = field.mask(); - if (mask != null) { - fields.add(CodeBlock.of("$S, $T.mask($L)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, this.maskRuleCode(mask, typeMask))); - continue; - } - var nestedType = this.nestedType(field.type()); - if (nestedType == null) { - continue; - } - if (CommonUtils.isCollection(field.type())) { - fields.add(CodeBlock.of("$S, $T.collection($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); - } else if (CommonUtils.isMap(field.type())) { - fields.add(CodeBlock.of("$S, $T.mapValue($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); - } else { - fields.add(CodeBlock.of("$S, $T.object($T.class)", field.jsonName(), LogAspectClassNames.maskingFieldMeta, TypeName.get(nestedType.asType()))); - } - } - if (fields.isEmpty()) { - return CodeBlock.of("$T.of()", MAP); - } - - var code = CodeBlock.builder().add("$T.ofEntries(\n$>", MAP); - for (int i = 0; i < fields.size(); i++) { - code.add("$T.entry($L)", MAP, fields.get(i)); - if (i < fields.size() - 1) { - code.add(",\n"); - } - } - return code.add("$<\n)").build(); - } - - private CodeBlock maskRuleCode(AnnotationMirror mask, AnnotationMirror typeMask) { - var value = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "value"); - if (value == null) { - value = AnnotationUtils.parseAnnotationValue(this.elements, typeMask, "value"); - } - if (value == null) { - value = "***"; - } - - var mode = this.mode(mask); - if (mode == null) { - mode = this.mode(typeMask); - } - if (mode == null) { - mode = "FULL"; - } - - var keep = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "keep"); - if (keep == null) { - keep = AnnotationUtils.parseAnnotationValue(this.elements, typeMask, "keep"); - } - if (keep == null) { - keep = 4; - } - - return CodeBlock.of("$T.replacement($S, $T.$L, $L)", LogAspectClassNames.maskRule, value, MASK_MODE, mode, keep); - } - - private String mode(AnnotationMirror mask) { - var mode = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "mode"); - return mode == null ? null : mode.getSimpleName().toString(); - } - - private void visit(TypeElement type, Map visited) { - var key = type.getQualifiedName().toString(); - if (visited.containsKey(key)) { - return; - } - if (!this.isJsonOrMasked(type)) { - return; - } - var meta = this.parse(type); - visited.put(key, meta); - for (var field : meta.fields()) { - if (field.mask() != null) { - continue; - } - for (var nested : this.nestedTypes(field.type())) { - this.visit(nested, visited); - } - } - } - - private MaskingClassMeta parse(TypeElement type) { - var fields = new ArrayList(); - var nameConverter = CommonUtils.getNameConverter(type); - if (type.getKind() == ElementKind.RECORD) { - for (var enclosed : type.getEnclosedElements()) { - if (!(enclosed instanceof RecordComponentElement component)) { - continue; - } - if (AnnotationUtils.findAnnotation(component, LogAspectClassNames.jsonSkip) != null) { - continue; - } - var accessor = this.findRecordAccessor(type, component); - var jsonName = this.jsonName(component, nameConverter); - var mask = findMask(component, accessor); - fields.add(new MaskingField(jsonName, component.asType(), mask)); - } - } else { - for (var enclosed : type.getEnclosedElements()) { - if (enclosed.getKind() != ElementKind.FIELD || !(enclosed instanceof VariableElement field)) { - continue; - } - if (field.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) { - continue; - } - if (AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonSkip) != null) { - continue; - } - var jsonName = this.jsonName(field, nameConverter); - var accessor = this.findAccessor(type, field); - var mask = accessor == null ? findMask(field) : findMask(field, accessor); - fields.add(new MaskingField(jsonName, field.asType(), mask)); - } - } - return new MaskingClassMeta(type, fields); - } - - private String jsonName(Element field, CommonUtils.NameConverter nameConverter) { - var jsonField = AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonField); - if (jsonField != null) { - var value = AnnotationUtils.parseAnnotationValueWithoutDefault(jsonField, "value"); - if (value != null && !value.isBlank()) { - return value; - } - } - var name = field.getSimpleName().toString(); - return nameConverter == null ? name : nameConverter.convert(name); - } - - private ExecutableElement findRecordAccessor(TypeElement type, RecordComponentElement component) { - return CommonUtils.findMethods(type, m -> m.contains(javax.lang.model.element.Modifier.PUBLIC)) - .stream() - .filter(m -> m.getParameters().isEmpty()) - .filter(m -> m.getSimpleName().contentEquals(component.getSimpleName())) - .findFirst() - .orElse(null); - } - - private ExecutableElement findAccessor(TypeElement type, VariableElement field) { - var name = field.getSimpleName().toString(); - var capitalized = CommonUtils.capitalize(name); - return CommonUtils.findMethods(type, m -> m.contains(javax.lang.model.element.Modifier.PUBLIC)) - .stream() - .filter(m -> m.getParameters().isEmpty()) - .filter(m -> m.getSimpleName().contentEquals(name) || m.getSimpleName().contentEquals("get" + capitalized)) - .findFirst() - .orElse(null); - } - - private TypeElement nestedType(TypeMirror type) { - var nestedTypes = this.nestedTypes(type); - if (nestedTypes.size() != 1) { - return null; - } - return nestedTypes.get(0); - } - - private List nestedTypes(TypeMirror type) { - if (type.getKind() != TypeKind.DECLARED || !(type instanceof DeclaredType declaredType)) { - return List.of(); - } - if (CommonUtils.isCollection(type)) { - if (declaredType.getTypeArguments().isEmpty()) { - return List.of(); - } - return this.nestedTypes(declaredType.getTypeArguments().get(0)); - } else if (CommonUtils.isMap(type)) { - if (declaredType.getTypeArguments().size() < 2) { - return List.of(); - } - return this.nestedTypes(declaredType.getTypeArguments().get(1)); - } - - var element = this.types.asElement(type); - if (!(element instanceof TypeElement typeElement)) { - return List.of(); - } - if (!this.isJsonOrMasked(typeElement)) { - return List.of(); - } - return List.of(typeElement); - } - - private boolean isJsonOrMasked(TypeElement typeElement) { - return AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.json) != null - || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.jsonWriter) != null - || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.mask) != null; - } - - private static AnnotationMirror findMask(Element... elements) { - for (var element : elements) { - var mask = AnnotationUtils.findAnnotation(element, LogAspectClassNames.mask); - if (mask != null) { - return mask; - } - } - return null; - } - - public static String metadataName(TypeElement type) { - return NameUtils.generatedType(type, "MaskingMetadata"); - } - - private record MaskingClassMeta(TypeElement type, List fields) {} - - private record MaskingField(String jsonName, TypeMirror type, AnnotationMirror mask) {} -} diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java index 9ed24066d..547b34989 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspect.java @@ -11,8 +11,11 @@ import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; import java.util.*; import static io.koraframework.logging.annotation.processor.aop.LogAspectClassNames.*; @@ -85,14 +88,7 @@ private ApplyResult blockingBody(AspectContext aspectContext, ExecutableElement var logResultLevel = logResultLevel(executableElement, logOutLevel, env); final CodeBlock resultWriter; if (!isVoid && logResultLevel != null) { - var mapping = this.structuredArgumentMapping(executableElement); - var mapperType = mapping != null && mapping.mapperClass() != null - ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(executableElement.getReturnType())) : TypeName.get(mapping.mapperClass()) - : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(executableElement.getReturnType()).box()); - var mapper = aspectContext.fieldFactory().constructorParam( - mapperType.annotated(CommonClassNames.nullableAnnotation), - mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) - ); + var mapper = this.structuredArgumentMapperField(aspectContext, executableElement, TypeName.get(executableElement.getReturnType()).box()); var resultWriterBuilder = CodeBlock.builder().beginControlFlow("gen ->") .addStatement("gen.writeStartObject()") .beginControlFlow("if (this.$N != null)", mapper) @@ -173,14 +169,7 @@ private ApplyResult completionStageBody(AspectContext aspectContext, ExecutableE final CodeBlock resultWriter; b.beginControlFlow(".whenComplete(($L, $L) -> ", RESULT_VAR_NAME, ERROR_VAR_NAME); if (!isVoid && logResultLevel != null) { - var mapping = this.structuredArgumentMapping(executableElement); - var mapperType = mapping != null && mapping.mapperClass() != null - ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(methodGeneric)) : TypeName.get(mapping.mapperClass()) - : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(methodGeneric)); - var mapper = aspectContext.fieldFactory().constructorParam( - mapperType.annotated(CommonClassNames.nullableAnnotation), - mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) - ); + var mapper = this.structuredArgumentMapperField(aspectContext, executableElement, TypeName.get(methodGeneric).box()); var resultWriterBuilder = CodeBlock.builder().add("gen -> {$>\n") .add("gen.writeStartObject();\n") .beginControlFlow("if (this.$N != null)", mapper) @@ -310,14 +299,7 @@ private LogInMarker logInMarker(AspectContext aspectContext, String loggerField, b.beginControlFlow("if ($N.$N())", loggerField, "is" + CommonUtils.capitalize(level.toLowerCase()) + "Enabled"); } for (var param : paramsForLevel) { - var mapping = this.structuredArgumentMapping(param); - var mapperType = mapping != null && mapping.mapperClass() != null - ? mapping.isGeneric() ? mapping.parameterized(TypeName.get(param.asType())) : TypeName.get(mapping.mapperClass()) - : ParameterizedTypeName.get(structuredArgumentMapper, TypeName.get(param.asType()).box()); - var mapper = aspectContext.fieldFactory().constructorParam( - mapperType.annotated(CommonClassNames.nullableAnnotation), - mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) - ); + var mapper = this.structuredArgumentMapperField(aspectContext, param, TypeName.get(param.asType()).box()); b.beginControlFlow("if (this.$N != null)", mapper); b.addStatement("gen.writeName($S)", param.getSimpleName()); b.addStatement("this.$N.write(gen, $N)", mapper, param.getSimpleName()); @@ -341,14 +323,76 @@ private CodeBlock.Builder ifLogLevelEnabled(CodeBlock.Builder cb, String loggerF return cb; } - private CommonUtils.MappingData structuredArgumentMapping(Element element) { - var mapping = CommonUtils.parseMapping(element).getMapping(structuredArgumentMapper); - if (mapping != null) { - return mapping; + private ClassName structuredArgumentMapperInterface(Element element) { + return AnnotationUtils.findAnnotation(element, mask) == null + ? structuredArgumentMapper + : maskingStructuredArgumentMapper; + } + + private CommonUtils.MappingData structuredArgumentMapping(Element element, ClassName mapperInterface) { + return CommonUtils.parseMapping(element).getMapping(mapperInterface); + } + + private String structuredArgumentMapperField(AspectContext aspectContext, Element element, TypeName valueType) { + var mapperInterface = this.structuredArgumentMapperInterface(element); + var mapping = this.structuredArgumentMapping(element, mapperInterface); + var rulesMapping = AnnotationUtils.findAnnotation(element, mask) == null + ? null + : this.maskingRulesMapping(element); + if (rulesMapping != null && rulesMapping.mapperClass() != null && (mapping == null || mapping.mapperClass() == null)) { + return this.maskedStructuredArgumentMapperField(aspectContext, element, valueType, rulesMapping); } - if (AnnotationUtils.findAnnotation(element, mask) != null) { - return new CommonUtils.MappingData(null, mask.canonicalName()); + + var mapperType = mapping != null && mapping.mapperClass() != null + ? mapping.isGeneric() ? mapping.parameterized(valueType) : TypeName.get(mapping.mapperClass()) + : ParameterizedTypeName.get(mapperInterface, valueType); + return aspectContext.fieldFactory().constructorParam( + mapperType.annotated(CommonClassNames.nullableAnnotation), + mapping == null || mapping.toTagAnnotation() == null ? List.of() : List.of(mapping.toTagAnnotation()) + ); + } + + private String maskedStructuredArgumentMapperField(AspectContext aspectContext, Element element, TypeName valueType, CommonUtils.MappingData rulesMapping) { + var writerType = ParameterizedTypeName.get(jsonWriterInterface, valueType); + var writer = aspectContext.fieldFactory().constructorParam(writerType, List.of()); + var rulesType = rulesMapping.isGeneric() + ? rulesMapping.parameterized(valueType) + : TypeName.get(Objects.requireNonNull(rulesMapping.mapperClass())); + var rules = aspectContext.fieldFactory().constructorParam( + rulesType, + rulesMapping.toTagAnnotation() == null ? List.of() : List.of(rulesMapping.toTagAnnotation()) + ); + var mapperType = ParameterizedTypeName.get(maskingStructuredArgumentMapper, valueType); + return aspectContext.fieldFactory().constructorInitialized( + mapperType, + CodeBlock.of("new $T<>($N, $N, $L)", maskingStructuredArgumentMapper, writer, rules, AnnotationUtils.findAnnotation(element, json) != null) + ); + } + + private CommonUtils.MappingData maskingRulesMapping(Element element) { + var mappers = CommonUtils.parseMapping(element); + for (var mapperClass : Objects.requireNonNullElse(mappers.mapperClasses(), List.of())) { + if (this.isMaskingRules(mapperClass)) { + return new CommonUtils.MappingData(mapperClass, mappers.tag()); + } } return null; } + + private boolean isMaskingRules(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED || !(type instanceof DeclaredType declaredType)) { + return false; + } + var typeElement = (TypeElement) declaredType.asElement(); + if (ClassName.get(typeElement).equals(maskingRules)) { + return true; + } + for (var anInterface : typeElement.getInterfaces()) { + if (this.isMaskingRules(anInterface)) { + return true; + } + } + var superclass = typeElement.getSuperclass(); + return superclass != null && superclass.getKind() != TypeKind.NONE && this.isMaskingRules(superclass); + } } diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java index 6cb9e1458..4a6182252 100644 --- a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/aop/LogAspectClassNames.java @@ -11,14 +11,14 @@ public class LogAspectClassNames { public static final ClassName mask = ClassName.get("io.koraframework.logging.common.annotation", "Mask"); public static final ClassName json = ClassName.get("io.koraframework.json.common.annotation", "Json"); public static final ClassName jsonWriter = ClassName.get("io.koraframework.json.common.annotation", "JsonWriter"); + public static final ClassName jsonWriterInterface = ClassName.get("io.koraframework.json.common", "JsonWriter"); public static final ClassName jsonField = ClassName.get("io.koraframework.json.common.annotation", "JsonField"); public static final ClassName jsonSkip = ClassName.get("io.koraframework.json.common.annotation", "JsonSkip"); public static final ClassName structuredArgument = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgument"); public static final ClassName structuredArgumentMapper = ClassName.get("io.koraframework.logging.common.arg", "StructuredArgumentMapper"); - public static final ClassName maskingMetadata = ClassName.get("io.koraframework.logging.common.masking", "MaskingMetadata"); - public static final ClassName maskingClassMeta = ClassName.get("io.koraframework.logging.common.masking", "MaskingClassMeta"); - public static final ClassName maskingFieldMeta = ClassName.get("io.koraframework.logging.common.masking", "MaskingFieldMeta"); - public static final ClassName maskRule = ClassName.get("io.koraframework.logging.common.masking", "MaskRule"); + public static final ClassName maskingStructuredArgumentMapper = ClassName.get("io.koraframework.logging.common.arg", "MaskedStructuredArgumentMapper"); + public static final ClassName maskingRules = ClassName.get("io.koraframework.logging.common.masking", "MaskingRules"); + public static final ClassName maskingFull = ClassName.get("io.koraframework.logging.common.masking", "MaskingFull"); public static final ClassName loggerFactory = ClassName.get("org.slf4j", "ILoggerFactory"); public static final ClassName logger = ClassName.get("org.slf4j", "Logger"); diff --git a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java index d569e2865..26d291eda 100644 --- a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java +++ b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/AbstractLogAspectTest.java @@ -41,6 +41,12 @@ protected String commonImports() { return super.commonImports() + """ import io.koraframework.logging.common.annotation.Log; import io.koraframework.logging.common.annotation.Mask; + import io.koraframework.logging.common.masking.MaskingFull; + import io.koraframework.logging.common.masking.MaskingKeepFirst; + import io.koraframework.logging.common.masking.MaskingKeepLast; + import io.koraframework.logging.common.masking.MaskingRules; + import io.koraframework.logging.common.masking.MaskingStrategy; + import io.koraframework.common.annotation.Mapping; import io.koraframework.json.common.annotation.Json; import io.koraframework.json.common.annotation.JsonField; import static org.slf4j.event.Level.*; diff --git a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java index 9ccffd2c0..088745a33 100644 --- a/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java +++ b/logging/logging-annotation-processor/src/test/java/io/koraframework/logging/aspect/LogAspectTest.java @@ -7,9 +7,13 @@ import io.koraframework.json.common.JsonWriter; import io.koraframework.kora.app.annotation.processor.KoraAppProcessor; import io.koraframework.logging.annotation.processor.LoggingAnnotationProcessor; +import io.koraframework.logging.common.LoggingModule; import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper; import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; -import io.koraframework.logging.common.masking.MaskingMetadata; +import io.koraframework.logging.common.masking.MaskingFull; +import io.koraframework.logging.common.masking.MaskingKeepFirst; +import io.koraframework.logging.common.masking.MaskingKeepLast; +import io.koraframework.logging.common.masking.MaskingRules; import io.koraframework.logging.common.arg.StructuredArgumentWriter; import io.koraframework.json.common.writer.ListJsonWriter; import io.koraframework.json.common.writer.MapJsonWriter; @@ -17,6 +21,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -26,6 +32,29 @@ public class LogAspectTest extends AbstractLogAspectTest { + @Test + public void testLoggingModuleMaskedMappersUseDifferentOutputModes() { + record User(String token) {} + + var module = new LoggingModule() {}; + JsonWriter writer = (gen, user) -> { + gen.writeStartObject(); + gen.writeStringProperty("token", user.token()); + gen.writeEndObject(); + }; + var rules = MaskingRules.builder(User.class) + .mask("token", new MaskingFull()) + .build(); + + var plainMapper = module.maskedStructuredArgumentMapper(writer, rules); + var jsonMapper = module.jsonMaskedStructuredArgumentMapper(writer, rules); + + org.assertj.core.api.Assertions.assertThat(plainMapper.writeToString(new User("secret"))) + .isEqualTo("\"{\\\"token\\\":\\\"***\\\"}\""); + org.assertj.core.api.Assertions.assertThat(jsonMapper.writeToString(new User("secret"))) + .isEqualTo("{\"token\":\"***\"}"); + } + @Test public void testLogPrintsInAndOut() { var aopProxy = compile(""" @@ -424,7 +453,7 @@ public void write(JsonGenerator gen, T value) { } @Test - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked"}) public void testLogArgsWithJsonMapperTag() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ @Json @@ -456,12 +485,12 @@ public void test(@Json TestRecord arg1) {} } @Test - @SuppressWarnings({"unchecked", "rawtypes"}) - public void testLogArgsWithMaskedMapperTag() { + @SuppressWarnings({"unchecked"}) + public void testLogArgsWithMaskingMapper() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ - @Mask(mode = Mask.Mode.KEEP_FIRST, keep = 1) + @Mask(MaskingKeepFirst.class) @Json - public record Credentials(@JsonField("secret") @Mask String secret, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) String token, String login) {} + public record Credentials(@JsonField("secret") @Mask String secret, @Mask(MaskingKeepLast.class) String token, String login) {} """, """ @Mask @Json @@ -469,14 +498,14 @@ public record User(String name, Credentials credentials) {} """, """ public class Target { @Log.in - public void test(@Mask User arg1) {} + public void test(@Mask @Json User arg1) {} } """); compileResult.assertSuccess(); var credentialsWriter = (JsonWriter) newObject("$Credentials_JsonWriter"); var userWriter = (JsonWriter) newObject("$User_JsonWriter", credentialsWriter); - var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); - var mapper = new MaskedStructuredArgumentMapper<>(userWriter, metadata); + var rules = maskingRules("$User_MaskingRulesModule", new MaskingKeepFirst("###", 2), new MaskingKeepLast("!!!", 3)); + var mapper = new MaskedStructuredArgumentMapper<>(userWriter, rules); var aopProxy = new TestObject( compileResult.loadClass("$Target__AopProxy"), newObject("$Target__AopProxy", factory, mapper) @@ -493,11 +522,90 @@ public void test(@Mask User arg1) {} o.verify(log).isDebugEnabled(); o.verify(log).info(inData.capture(), eq(">")); o.verifyNoMoreInteractions(); - verifyInJson("{\"arg1\":{\"name\":\"user\",\"credentials\":{\"secret\":\"s***\",\"token\":\"***en\",\"login\":\"login\"}}}"); + verifyInJson("{\"arg1\":{\"name\":\"user\",\"credentials\":{\"secret\":\"se###\",\"token\":\"!!!ken\",\"login\":\"login\"}}}"); + } + + @Test + @SuppressWarnings({"unchecked"}) + public void testLogArgsWithCustomMaskingStrategy() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + public final class CustomMaskingStrategy implements MaskingStrategy { + public String mask(Object value) { + return "custom-" + value; + } + } + """, """ + @Mask + @Json + public record User(String name, @Mask(CustomMaskingStrategy.class) String token) {} + """, """ + public class Target { + @Log.in + public void test(@Mask @Json User arg1) {} + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$User_JsonWriter"); + var rules = maskingRules("$User_MaskingRulesModule", newObject("CustomMaskingStrategy")); + var mapper = new MaskedStructuredArgumentMapper<>(writer, rules); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, mapper) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test", newObject("User", "user", "secret")); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(inData.capture(), eq(">")); + o.verifyNoMoreInteractions(); + verifyInJson("{\"arg1\":{\"name\":\"user\",\"token\":\"custom-secret\"}}"); + } + + @Test + @SuppressWarnings({"unchecked"}) + public void testLogArgsWithCustomMaskingRulesMapping() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask + @Json + public record User(String name, @Mask String token) {} + """, """ + public final class CustomRules extends MaskingRules { + public CustomRules() { + super(User.class, java.util.Map.of("token", value -> "rules-" + value)); + } + } + """, """ + public class Target { + @Log.in + public void test(@Mask @Mapping(CustomRules.class) User arg1) {} + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$User_JsonWriter"); + var rules = newObject("CustomRules"); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, writer, rules) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test", newObject("User", "user", "secret")); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(inData.capture(), eq(">")); + o.verifyNoMoreInteractions(); + verifyInJson("{\"arg1\":\"{\\\"name\\\":\\\"user\\\",\\\"token\\\":\\\"rules-secret\\\"}\"}"); } @Test - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked"}) public void testLogResultWithJsonMapperTag() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ @Json @@ -532,16 +640,17 @@ public TestRecord test() { } @Test - @SuppressWarnings({"unchecked", "rawtypes"}) - public void testLogResultWithMaskedMapperTag() { + @SuppressWarnings({"unchecked"}) + public void testLogResultWithMaskingMapper() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ @Mask @Json - public record User(String name, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) String token) {} + public record User(String name, @Mask(MaskingKeepLast.class) String token) {} """, """ public class Target { @Log.out @Mask + @Json public User test() { return new User("user", "secret"); } @@ -549,8 +658,8 @@ public User test() { """); compileResult.assertSuccess(); var writer = (JsonWriter) newObject("$User_JsonWriter"); - var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); - var mapper = new MaskedStructuredArgumentMapper<>(writer, metadata); + var rules = maskingRules("$User_MaskingRulesModule", new MaskingKeepLast()); + var mapper = new MaskedStructuredArgumentMapper<>(writer, rules); var aopProxy = new TestObject( compileResult.loadClass("$Target__AopProxy"), newObject("$Target__AopProxy", factory, mapper) @@ -565,11 +674,54 @@ public User test() { o.verify(log).isDebugEnabled(); o.verify(log).info(outData.capture(), eq("<")); o.verifyNoMoreInteractions(); - verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"***et\"}}"); + verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"***cret\"}}"); + } + + @Test + @SuppressWarnings({"unchecked"}) + public void testLogResultWithCustomMaskingRulesMapping() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask + @Json + public record User(String name, @Mask String token) {} + """, """ + public final class CustomRules extends MaskingRules { + public CustomRules() { + super(User.class, java.util.Map.of("token", value -> "rules-" + value)); + } + } + """, """ + public class Target { + @Log.out + @Mask + @Mapping(CustomRules.class) + public User test() { + return new User("user", "secret"); + } + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$User_JsonWriter"); + var rules = newObject("CustomRules"); + var aopProxy = new TestObject( + compileResult.loadClass("$Target__AopProxy"), + newObject("$Target__AopProxy", factory, writer, rules) + ); + + verify(factory).getLogger(testPackage() + ".Target.test"); + var log = Objects.requireNonNull(loggers.get(testPackage() + ".Target.test")); + + reset(log, DEBUG); + aopProxy.invoke("test"); + var o = Mockito.inOrder(log); + o.verify(log).isDebugEnabled(); + o.verify(log).info(outData.capture(), eq("<")); + o.verifyNoMoreInteractions(); + verifyOutJson("{\"out\":\"{\\\"name\\\":\\\"user\\\",\\\"token\\\":\\\"rules-secret\\\"}\"}"); } @Test - public void testMaskingMetadataSupportsRecursiveType() { + public void testMaskingRulesSupportsRecursiveType() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor()), """ @Mask @Json @@ -579,8 +731,8 @@ public record User(String name, @Mask String token, User manager) {} } @Test - @SuppressWarnings({"unchecked", "rawtypes"}) - public void testMaskingMetadataSupportsNestedGenericContainers() { + @SuppressWarnings({"unchecked"}) + public void testMaskingRulesSupportsNestedGenericContainers() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ @Mask @Json @@ -601,8 +753,8 @@ public void test(@Mask User arg1) {} var nestedListWriter = new ListJsonWriter<>(listWriter); var nestedMapWriter = new MapJsonWriter<>(listWriter); var userWriter = (JsonWriter) newObject("$User_JsonWriter", nestedListWriter, nestedMapWriter); - var metadata = (MaskingMetadata) newObject("$User_MaskingMetadata"); - var mapper = new MaskedStructuredArgumentMapper<>(userWriter, metadata); + var rules = maskingRules("$User_MaskingRulesModule", new MaskingFull()); + var mapper = new MaskedStructuredArgumentMapper<>(userWriter, rules); var aopProxy = new TestObject( compileResult.loadClass("$Target__AopProxy"), newObject("$Target__AopProxy", factory, mapper) @@ -624,7 +776,7 @@ public void test(@Mask User arg1) {} } @Test - public void testMaskingMetadataComponentIsResolvedByGraph() { + public void testMaskingRulesComponentIsResolvedByGraph() { compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new KoraAppProcessor()), """ @Mask @Json @@ -632,16 +784,20 @@ public record User(@Mask String password) {} """, """ @KoraApp public interface TestApp extends io.koraframework.json.common.JsonModule { - @Tag(Mask.class) - default io.koraframework.logging.common.arg.StructuredArgumentMapper maskedStructuredArgumentMapper( + default MaskingFull maskingFull() { + return new MaskingFull(); + } + + @Json + default io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper maskedStructuredArgumentMapper( io.koraframework.json.common.JsonWriter writer, - io.koraframework.logging.common.masking.MaskingMetadata metadata + io.koraframework.logging.common.masking.MaskingRules rules ) { - return new io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper<>(writer, metadata); + return new io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper<>(writer, rules); } @Root - default Object root(@Tag(Mask.class) io.koraframework.logging.common.arg.StructuredArgumentMapper mapper) { + default Object root(@Json io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper mapper) { return mapper; } } @@ -659,4 +815,19 @@ private void verifyOutJson(String expectedJson) { org.assertj.core.api.Assertions.assertThat(writer.writeToString()).isEqualTo(expectedJson); } + @SuppressWarnings("unchecked") + private MaskingRules maskingRules(String moduleName, Object... args) { + try { + var module = compileResult.loadClass(moduleName); + var proxy = Proxy.newProxyInstance(module.getClassLoader(), new Class[]{module}, (p, method, methodArgs) -> InvocationHandler.invokeDefault(p, method, methodArgs)); + var method = java.util.Arrays.stream(module.getMethods()) + .filter(m -> m.getReturnType().equals(MaskingRules.class)) + .findFirst() + .orElseThrow(); + return (MaskingRules) method.invoke(proxy, args); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java index 9c7b271e0..f744344a2 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/LoggingModule.java @@ -2,16 +2,17 @@ import io.koraframework.common.annotation.DefaultComponent; import io.koraframework.common.annotation.Root; -import io.koraframework.common.annotation.Tag; import io.koraframework.config.common.Config; import io.koraframework.config.common.mapper.ConfigValueMapper; import io.koraframework.json.common.JsonWriter; import io.koraframework.json.common.annotation.Json; -import io.koraframework.logging.common.annotation.Mask; import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper; import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; import io.koraframework.logging.common.arg.StructuredArgumentMapper; -import io.koraframework.logging.common.masking.MaskingMetadata; +import io.koraframework.logging.common.masking.MaskingFull; +import io.koraframework.logging.common.masking.MaskingKeepFirst; +import io.koraframework.logging.common.masking.MaskingKeepLast; +import io.koraframework.logging.common.masking.MaskingRules; import org.slf4j.ILoggerFactory; import org.slf4j.LoggerFactory; @@ -31,15 +32,35 @@ default ILoggerFactory loggerFactory() { return LoggerFactory.getILoggerFactory(); } + @DefaultComponent + default MaskingFull maskingStrategyFull() { + return new MaskingFull(); + } + + @DefaultComponent + default MaskingKeepFirst maskingStrategyKeepFirst() { + return new MaskingKeepFirst(); + } + + @DefaultComponent + default MaskingKeepLast maskingStrategyKeepLast() { + return new MaskingKeepLast(); + } + @Json @DefaultComponent default StructuredArgumentMapper jsonStructuredArgumentMapper(JsonWriter writer) { return new JsonStructuredArgumentMapper<>(writer); } - @Tag(Mask.class) @DefaultComponent - default StructuredArgumentMapper maskedStructuredArgumentMapper(JsonWriter writer, MaskingMetadata metadata) { - return new MaskedStructuredArgumentMapper<>(writer, metadata); + default MaskedStructuredArgumentMapper maskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules) { + return new MaskedStructuredArgumentMapper<>(writer, rules, false); + } + + @Json + @DefaultComponent + default MaskedStructuredArgumentMapper jsonMaskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules) { + return new MaskedStructuredArgumentMapper<>(writer, rules, true); } } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java index 0036084d7..987d9b45f 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java @@ -1,5 +1,8 @@ package io.koraframework.logging.common.annotation; +import io.koraframework.logging.common.masking.MaskingFull; +import io.koraframework.logging.common.masking.MaskingStrategy; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -9,15 +12,5 @@ @Retention(RetentionPolicy.RUNTIME) public @interface Mask { - String value() default "***"; - - Mode mode() default Mode.FULL; - - int keep() default 4; - - enum Mode { - FULL, - KEEP_LAST, - KEEP_FIRST - } + Class value() default MaskingFull.class; } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java index 8c53d043f..2558591f2 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java @@ -1,21 +1,43 @@ package io.koraframework.logging.common.arg; import io.koraframework.json.common.JsonWriter; +import io.koraframework.json.common.JsonModule; import io.koraframework.logging.common.masking.MaskingJsonGenerator; -import io.koraframework.logging.common.masking.MaskingMetadata; +import io.koraframework.logging.common.masking.MaskingRules; import tools.jackson.core.JsonGenerator; +import tools.jackson.core.io.SegmentedStringWriter; + +public class MaskedStructuredArgumentMapper implements StructuredArgumentMapper { -public final class MaskedStructuredArgumentMapper implements StructuredArgumentMapper { private final JsonWriter writer; - private final MaskingMetadata metadata; + private final MaskingRules rules; + private final boolean structured; + + public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules) { + this(writer, rules, true); + } - public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingMetadata metadata) { + public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules, boolean structured) { this.writer = writer; - this.metadata = metadata; + this.rules = rules; + this.structured = structured; } @Override public void write(JsonGenerator gen, T value) { - this.writer.write(new MaskingJsonGenerator(gen, this.metadata), value); + if (this.structured) { + this.writer.write(new MaskingJsonGenerator(gen, this.rules), value); + return; + } + gen.writeString(this.writeMaskedAsString(value)); + } + + private String writeMaskedAsString(T value) { + try (var sw = new SegmentedStringWriter(JsonModule.JSON_FACTORY._getBufferRecycler()); + var gen = JsonModule.JSON_FACTORY.createGenerator(sw)) { + this.writer.write(new MaskingJsonGenerator(gen, this.rules), value); + gen.flush(); + return sw.getAndClear(); + } } } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgument.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgument.java index 4407edbe9..51f3f4b54 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgument.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgument.java @@ -8,6 +8,7 @@ import java.util.Map; public interface StructuredArgument extends StructuredArgumentWriter { + String fieldName(); static StructuredArgument arg(String fieldName, @Nullable T value, JsonWriter writer) { diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentMapper.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentMapper.java index 2962e76e3..c19ec1209 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentMapper.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentMapper.java @@ -1,8 +1,20 @@ package io.koraframework.logging.common.arg; import io.koraframework.common.annotation.Mapping; +import io.koraframework.json.common.JsonModule; import tools.jackson.core.JsonGenerator; +import tools.jackson.core.io.SegmentedStringWriter; public interface StructuredArgumentMapper extends Mapping.MappingFunction { + void write(JsonGenerator gen, T value); + + default String writeToString(T value) { + try (var sw = new SegmentedStringWriter(JsonModule.JSON_FACTORY._getBufferRecycler()); + var gen = JsonModule.JSON_FACTORY.createGenerator(sw)) { + this.write(gen, value); + gen.flush(); + return sw.getAndClear(); + } + } } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentWriter.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentWriter.java index 754eec347..f49bec129 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentWriter.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/StructuredArgumentWriter.java @@ -5,6 +5,7 @@ import tools.jackson.core.io.SegmentedStringWriter; public interface StructuredArgumentWriter { + void writeTo(JsonGenerator generator); default String writeToString() { diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java deleted file mode 100644 index 9319ba015..000000000 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskRule.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.koraframework.logging.common.masking; - -import io.koraframework.logging.common.annotation.Mask; -import org.jspecify.annotations.Nullable; - -public record MaskRule(String replacement, Mask.Mode mode, int keep) { - - public static MaskRule replacement(String replacement, Mask.Mode mode, int keep) { - return new MaskRule(replacement, mode, keep); - } - - public String apply(@Nullable Object value) { - if (this.mode == Mask.Mode.FULL || value == null) { - return this.replacement; - } - - var stringValue = value.toString(); - return switch (this.mode) { - case FULL -> this.replacement; - case KEEP_LAST -> this.keepLast(stringValue); - case KEEP_FIRST -> this.keepFirst(stringValue); - }; - } - - private String keepLast(String value) { - if (value.length() <= this.keep) { - return this.replacement; - } - return this.replacement + value.substring(value.length() - this.keep); - } - - private String keepFirst(String value) { - if (value.length() <= this.keep) { - return this.replacement; - } - return value.substring(0, this.keep) + this.replacement; - } - -} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java deleted file mode 100644 index e7258c1e6..000000000 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingClassMeta.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.koraframework.logging.common.masking; - -import org.jspecify.annotations.Nullable; - -import java.util.Map; - -public record MaskingClassMeta(Map fields) { - @Nullable - public MaskingFieldMeta field(String name) { - return this.fields.get(name); - } -} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java deleted file mode 100644 index cb929e173..000000000 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFieldMeta.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.koraframework.logging.common.masking; - -import org.jspecify.annotations.Nullable; - -public record MaskingFieldMeta(Kind kind, @Nullable Class type, @Nullable MaskRule rule) { - public enum Kind { - MASK, - OBJECT, - COLLECTION, - MAP_VALUE - } - - public static MaskingFieldMeta mask(MaskRule rule) { - return new MaskingFieldMeta(Kind.MASK, null, rule); - } - - public static MaskingFieldMeta object(Class type) { - return new MaskingFieldMeta(Kind.OBJECT, type, null); - } - - public static MaskingFieldMeta collection(Class type) { - return new MaskingFieldMeta(Kind.COLLECTION, type, null); - } - - public static MaskingFieldMeta mapValue(Class type) { - return new MaskingFieldMeta(Kind.MAP_VALUE, type, null); - } -} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFull.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFull.java new file mode 100644 index 000000000..47dee30fb --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingFull.java @@ -0,0 +1,20 @@ +package io.koraframework.logging.common.masking; + +public final class MaskingFull implements MaskingStrategy { + private static final String DEFAULT_REPLACEMENT = "***"; + + private final String replacement; + + public MaskingFull() { + this(DEFAULT_REPLACEMENT); + } + + public MaskingFull(String replacement) { + this.replacement = replacement; + } + + @Override + public String mask(Object value) { + return this.replacement; + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java index 13a838acf..92407c77e 100644 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java @@ -9,36 +9,30 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; public final class MaskingJsonGenerator extends JsonGeneratorDelegate { - private final MaskingMetadata metadata; - private final Deque stack = new ArrayDeque<>(); + private final MaskingRules rules; + private final Deque path = new ArrayDeque<>(); + private final Deque pathSegmentPushed = new ArrayDeque<>(); + private @Nullable String pendingFieldName; private int suppressDepth; - public MaskingJsonGenerator(JsonGenerator delegate, MaskingMetadata metadata) { + public MaskingJsonGenerator(JsonGenerator delegate, MaskingRules rules) { super(delegate); - this.metadata = metadata; + this.rules = rules; } @Override public JsonGenerator writeStartObject(Object forValue) throws JacksonException { - if (this.suppressDepth > 0) { - this.suppressDepth++; - return this; - } - var pending = this.pending(); - if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { - this.writeMask(pending, null); - this.clearPending(); - this.suppressDepth = 1; - return this; - } - this.clearPending(); - super.writeStartObject(forValue); - this.stack.push(new Context(this.findMeta(forValue), null, null)); - return this; + return this.writeStartObject(); + } + + @Override + public JsonGenerator writeStartObject(Object forValue, int size) throws JacksonException { + return this.writeStartObject(); } @Override @@ -47,18 +41,22 @@ public JsonGenerator writeStartObject() throws JacksonException { this.suppressDepth++; return this; } - var pending = this.pending(); - if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { - this.writeMask(pending, null); - this.clearPending(); - this.suppressDepth = 1; - return this; + var fieldName = this.pendingFieldName; + if (fieldName != null) { + var strategy = this.strategy(fieldName); + if (strategy != null) { + this.writeMask(strategy, null); + this.pendingFieldName = null; + this.suppressDepth = 1; + return this; + } + this.path.addLast(fieldName); + this.pathSegmentPushed.push(true); + this.pendingFieldName = null; + } else { + this.pathSegmentPushed.push(false); } - var meta = this.findMeta(this.nextObjectType()); - this.clearPending(); - super.writeStartObject(); - this.stack.push(new Context(meta, null, null)); - return this; + return super.writeStartObject(); } @Override @@ -68,9 +66,7 @@ public JsonGenerator writeEndObject() throws JacksonException { return this; } super.writeEndObject(); - if (!this.stack.isEmpty()) { - this.stack.pop(); - } + this.popContainer(); return this; } @@ -80,21 +76,22 @@ public JsonGenerator writeStartArray() throws JacksonException { this.suppressDepth++; return this; } - var pending = this.pending(); - if (pending != null && pending.kind() == MaskingFieldMeta.Kind.MASK) { - this.writeMask(pending, null); - this.clearPending(); - this.suppressDepth = 1; - return this; + var fieldName = this.pendingFieldName; + if (fieldName != null) { + var strategy = this.strategy(fieldName); + if (strategy != null) { + this.writeMask(strategy, null); + this.pendingFieldName = null; + this.suppressDepth = 1; + return this; + } + this.path.addLast(fieldName); + this.pathSegmentPushed.push(true); + this.pendingFieldName = null; + } else { + this.pathSegmentPushed.push(false); } - var elementType = pending != null - && (pending.kind() == MaskingFieldMeta.Kind.COLLECTION || pending.kind() == MaskingFieldMeta.Kind.MAP_VALUE) - ? pending.type() - : null; - this.clearPending(); - super.writeStartArray(); - this.stack.push(new Context(null, null, elementType)); - return this; + return super.writeStartArray(); } @Override @@ -114,9 +111,7 @@ public JsonGenerator writeEndArray() throws JacksonException { return this; } super.writeEndArray(); - if (!this.stack.isEmpty()) { - this.stack.pop(); - } + this.popContainer(); return this; } @@ -126,9 +121,7 @@ public JsonGenerator writeName(String name) throws JacksonException { return this; } super.writeName(name); - if (!this.stack.isEmpty() && this.stack.peek().classMeta() != null) { - this.stack.peek().pending = this.stack.peek().classMeta().field(name); - } + this.pendingFieldName = name; return this; } @@ -245,74 +238,42 @@ private boolean skipOrMaskScalar(@Nullable Object value) throws JacksonException if (this.suppressDepth > 0) { return true; } - var pending = this.pending(); - if (pending != null) { - if (pending.kind() == MaskingFieldMeta.Kind.MASK) { - this.writeMask(pending, value); - } - this.clearPending(); - return pending.kind() == MaskingFieldMeta.Kind.MASK; + var fieldName = this.pendingFieldName; + if (fieldName == null) { + return false; } + var strategy = this.strategy(fieldName); + if (strategy != null) { + this.writeMask(strategy, value); + this.pendingFieldName = null; + return true; + } + this.pendingFieldName = null; return false; } - private void writeMask(MaskingFieldMeta fieldMeta, @Nullable Object value) throws JacksonException { - var rule = fieldMeta.rule(); - super.writeString(rule == null ? "***" : rule.apply(value)); - } - @Nullable - private MaskingFieldMeta pending() { - return this.stack.isEmpty() ? null : this.stack.peek().pending; + private MaskingStrategy strategy(String fieldName) { + var fullPath = new ArrayList(this.path.size() + 1); + fullPath.addAll(this.path); + fullPath.add(fieldName); + return this.rules.strategy(fullPath, fieldName); } - private void clearPending() { - if (!this.stack.isEmpty()) { - this.stack.peek().pending = null; + private void writeMask(MaskingStrategy strategy, @Nullable Object value) throws JacksonException { + if (value == null) { + super.writeNull(); + } else { + super.writeString(strategy.mask(value)); } } - @Nullable - private Class nextObjectType() { - var pending = this.pending(); - if (pending != null && pending.type() != null) { - return pending.type(); + private void popContainer() { + if (this.pathSegmentPushed.isEmpty()) { + return; } - if (!this.stack.isEmpty()) { - return this.stack.peek().arrayElementType(); - } - return null; - } - - @Nullable - private MaskingClassMeta findMeta(@Nullable Object value) { - return value == null ? null : this.metadata.metadata(value.getClass()); - } - - @Nullable - private MaskingClassMeta findMeta(@Nullable Class type) { - return type == null ? null : this.metadata.metadata(type); - } - - private static final class Context { - private final @Nullable MaskingClassMeta classMeta; - private @Nullable MaskingFieldMeta pending; - private final @Nullable Class arrayElementType; - - private Context(@Nullable MaskingClassMeta classMeta, @Nullable MaskingFieldMeta pending, @Nullable Class arrayElementType) { - this.classMeta = classMeta; - this.pending = pending; - this.arrayElementType = arrayElementType; - } - - @Nullable - private MaskingClassMeta classMeta() { - return this.classMeta; - } - - @Nullable - private Class arrayElementType() { - return this.arrayElementType; + if (this.pathSegmentPushed.pop() && !this.path.isEmpty()) { + this.path.removeLast(); } } } diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepFirst.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepFirst.java new file mode 100644 index 000000000..3f355dcbe --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepFirst.java @@ -0,0 +1,27 @@ +package io.koraframework.logging.common.masking; + +public final class MaskingKeepFirst implements MaskingStrategy { + private static final String DEFAULT_REPLACEMENT = "***"; + private static final int DEFAULT_KEEP = 4; + + private final String replacement; + private final int keep; + + public MaskingKeepFirst() { + this(DEFAULT_REPLACEMENT, DEFAULT_KEEP); + } + + public MaskingKeepFirst(String replacement, int keep) { + this.replacement = replacement; + this.keep = keep; + } + + @Override + public String mask(Object value) { + var stringValue = value.toString(); + if (stringValue.length() <= this.keep) { + return this.replacement; + } + return stringValue.substring(0, this.keep) + this.replacement; + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepLast.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepLast.java new file mode 100644 index 000000000..f64afb536 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingKeepLast.java @@ -0,0 +1,27 @@ +package io.koraframework.logging.common.masking; + +public final class MaskingKeepLast implements MaskingStrategy { + private static final String DEFAULT_REPLACEMENT = "***"; + private static final int DEFAULT_KEEP = 4; + + private final String replacement; + private final int keep; + + public MaskingKeepLast() { + this(DEFAULT_REPLACEMENT, DEFAULT_KEEP); + } + + public MaskingKeepLast(String replacement, int keep) { + this.replacement = replacement; + this.keep = keep; + } + + @Override + public String mask(Object value) { + var stringValue = value.toString(); + if (stringValue.length() <= this.keep) { + return this.replacement; + } + return this.replacement + stringValue.substring(stringValue.length() - this.keep); + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java deleted file mode 100644 index a31d132e5..000000000 --- a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingMetadata.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.koraframework.logging.common.masking; - -import org.jspecify.annotations.Nullable; - -public interface MaskingMetadata { - @Nullable - MaskingClassMeta metadata(Class type); -} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingRules.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingRules.java new file mode 100644 index 000000000..a43e62d12 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingRules.java @@ -0,0 +1,121 @@ +package io.koraframework.logging.common.masking; + +import io.koraframework.common.annotation.Mapping; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Describes JSON field masking rules for values logged through {@code @Mask}. + *

+ * A rule can target either a field name or a path from the logged root object: + *

    + *
  • {@code password} masks every JSON field named {@code password} wherever it appears;
  • + *
  • {@code user.password} masks only {@code password} fields reached through the {@code user} field;
  • + *
  • {@code users.*.password} can be used for map-like paths where {@code *} matches one dynamic path segment.
  • + *
+ * Annotation processors generate a default {@code MaskingRules} component for types annotated with {@code @Mask}. + * A custom rules implementation may extend this class and be selected for a logged parameter or result with + * {@code @Mapping(CustomRules.class)}. + * + * @param root type these rules are intended for + */ +public class MaskingRules implements Mapping.MappingFunction { + private final Class type; + private final List fields; + private final List paths; + + public MaskingRules(Class type, Map strategyMap) { + var builder = builder(type); + strategyMap.forEach(builder::mask); + var rules = builder.build(); + this(type, rules.fields, rules.paths); + } + + private MaskingRules(Class type, List fields, List paths) { + this.type = type; + this.fields = List.copyOf(fields); + this.paths = List.copyOf(paths); + } + + public static Builder builder(Class type) { + return new Builder<>(type); + } + + public Class type() { + return this.type; + } + + @Nullable + public MaskingStrategy strategy(List path, String fieldName) { + for (var rule : this.paths) { + if (rule.matches(path)) { + return rule.strategy(); + } + } + for (var rule : this.fields) { + if (rule.matchesField(fieldName)) { + return rule.strategy(); + } + } + return null; + } + + public static final class Builder { + private final Class type; + private final List fields = new ArrayList<>(); + private final List paths = new ArrayList<>(); + + private Builder(Class type) { + this.type = type; + } + + /** + * Adds a masking rule for a JSON field name or a dotted JSON path. + *

+ * A single segment such as {@code password} is matched by field name globally. + * A multi-segment value such as {@code user.password} is matched from the logged root object. + * The {@code *} segment is a wildcard that matches exactly one path segment, which is useful for map values + * whose JSON object field names are not known ahead of time. + * + * @param fieldOrPath field name or dotted path + * @param strategy strategy used to replace matched values + * @return this builder + */ + public Builder mask(String fieldOrPath, MaskingStrategy strategy) { + String[] splitted = fieldOrPath.split("\\."); + if (splitted.length == 1) { + this.fields.add(new Rule(List.of(splitted), strategy)); + } else { + this.paths.add(new Rule(List.of(splitted), strategy)); + } + return this; + } + + public MaskingRules build() { + return new MaskingRules<>(this.type, this.fields, this.paths); + } + } + + private record Rule(List path, MaskingStrategy strategy) { + + private boolean matches(List path) { + if (this.path.size() != path.size()) { + return false; + } + for (int i = 0; i < this.path.size(); i++) { + var segment = this.path.get(i); + if (!segment.equals("*") && !segment.equals(path.get(i))) { + return false; + } + } + return true; + } + + private boolean matchesField(String field) { + return this.path.size() == 1 && this.path.get(0).equals(field); + } + } +} diff --git a/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingStrategy.java b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingStrategy.java new file mode 100644 index 000000000..019590f2c --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingStrategy.java @@ -0,0 +1,27 @@ +package io.koraframework.logging.common.masking; + +/** + * Replaces a value matched by {@link MaskingRules} before it is written to a log marker. + *

+ * Implementations may be regular Kora components and can keep configuration in constructor parameters. + * Built-in implementations include full masking and keeping the first or last characters. + */ +public interface MaskingStrategy { + + /** + * Returns the value that should be written to the log instead of the original value. + *

+ * For scalar JSON values this method receives the Java value passed to the corresponding + * {@code JsonGenerator.write*()} method: + * {@link String}, {@link Boolean}, {@link Number}, {@link java.math.BigInteger}, + * {@link java.math.BigDecimal}, or {@code byte[]} for binary values. + * JSON null values are not masked and this method is not called for them. + * For object or array values matched as a whole, this method receives the source value passed to + * {@code JsonGenerator.writeStartObject(Object)} / {@code writeStartArray(Object)} when the underlying + * writer provides it. Map keys are not masked as values. + * + * @param value original JSON value + * @return replacement string to write to the log + */ + String mask(Object value); +} diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt deleted file mode 100644 index c87310df6..000000000 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessor.kt +++ /dev/null @@ -1,250 +0,0 @@ -package io.koraframework.logging.symbol.processor - -import com.google.devtools.ksp.processing.CodeGenerator -import com.google.devtools.ksp.processing.Resolver -import com.google.devtools.ksp.processing.SymbolProcessorEnvironment -import com.google.devtools.ksp.symbol.KSAnnotated -import com.google.devtools.ksp.symbol.KSAnnotation -import com.google.devtools.ksp.symbol.KSClassDeclaration -import com.google.devtools.ksp.symbol.KSType -import com.google.devtools.ksp.symbol.Modifier -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.CodeBlock -import com.squareup.kotlinpoet.FileSpec -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.STAR -import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.asClassName -import com.squareup.kotlinpoet.ksp.toClassName -import com.squareup.kotlinpoet.ksp.writeTo -import io.koraframework.ksp.common.AnnotationUtils.findAnnotation -import io.koraframework.ksp.common.AnnotationUtils.findValue -import io.koraframework.ksp.common.AnnotationUtils.findValueNoDefault -import io.koraframework.ksp.common.AnnotationUtils.isAnnotationPresent -import io.koraframework.ksp.common.BaseSymbolProcessor -import io.koraframework.ksp.common.CommonClassNames -import io.koraframework.ksp.common.CommonClassNames.isCollection -import io.koraframework.ksp.common.CommonClassNames.isMap -import io.koraframework.ksp.common.KspCommonUtils.addOriginatingKSFile -import io.koraframework.ksp.common.KspCommonUtils.generated -import io.koraframework.ksp.common.KspCommonUtils.getNameConverter -import io.koraframework.ksp.common.generatedClassName - -class MaskingMetadataSymbolProcessor( - environment: SymbolProcessorEnvironment -) : BaseSymbolProcessor(environment) { - - private val processed = HashSet() - private val codeGenerator: CodeGenerator = environment.codeGenerator - - override fun processRound(resolver: Resolver): List { - val delayed = ArrayList() - for (symbol in resolver.getSymbolsWithAnnotation(LoggingTypes.mask.canonicalName)) { - if (!symbol.validateAll()) { - delayed.add(symbol) - continue - } - if (symbol !is KSClassDeclaration) { - continue - } - if (symbol.modifiers.contains(Modifier.ABSTRACT)) { - kspLogger.error("Abstract classes can't be annotated with @Mask", symbol) - continue - } - if (!processed.add(symbol.qualifiedName!!.asString())) { - continue - } - MaskingMetadataGenerator(resolver, codeGenerator).generate(symbol) - } - return delayed - } -} - -private class MaskingMetadataGenerator( - private val resolver: Resolver, - private val codeGenerator: CodeGenerator -) { - fun generate(root: KSClassDeclaration) { - val packageName = root.packageName.asString() - val className = root.generatedClassName("MaskingMetadata") - if (resolver.getClassDeclarationByName(resolver.getKSNameFromString("$packageName.$className")) != null) { - return - } - - val visited = linkedMapOf() - visit(root, visited) - - val metadataType = LoggingTypes.maskingMetadata.parameterizedBy(root.toClassName()) - val type = TypeSpec.classBuilder(className) - .addModifiers(KModifier.PUBLIC) - .addAnnotation(CommonClassNames.component) - .generated(MaskingMetadataGenerator::class) - .addOriginatingKSFile(root) - .addSuperinterface(metadataType) - .primaryConstructor(FunSpec.constructorBuilder().build()) - .addProperty( - PropertySpec.builder( - "metadata", - Map::class.asClassName().parameterizedBy(Class::class.asClassName().parameterizedBy(STAR), LoggingTypes.maskingClassMeta), - KModifier.PRIVATE - ).initializer(metadataCode(visited.values)).build() - ) - .addFunction( - FunSpec.builder("metadata") - .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) - .addParameter("type", Class::class.asClassName().parameterizedBy(STAR)) - .returns(LoggingTypes.maskingClassMeta.copy(nullable = true)) - .addStatement("return this.metadata[type]") - .build() - ) - .build() - - FileSpec.builder(packageName, className) - .addType(type) - .build() - .writeTo(codeGenerator = codeGenerator, aggregating = false) - } - - private fun metadataCode(metas: Collection): CodeBlock { - val keyType = Class::class.asClassName().parameterizedBy(STAR) - if (metas.isEmpty()) { - return CodeBlock.of("java.util.Map.of<%T, %T>()", keyType, LoggingTypes.maskingClassMeta) - } - val code = CodeBlock.builder().add("java.util.Map.ofEntries<%T, %T>(\n", keyType, LoggingTypes.maskingClassMeta).indent() - val iterator = metas.iterator() - while (iterator.hasNext()) { - val meta = iterator.next() - code.add("java.util.Map.entry<%T, %T>(%T::class.java, %T(%L))", keyType, LoggingTypes.maskingClassMeta, meta.type.toClassName(), LoggingTypes.maskingClassMeta, fieldsCode(meta)) - if (iterator.hasNext()) { - code.add(",\n") - } - } - return code.unindent().add("\n)").build() - } - - private fun fieldsCode(meta: MaskingClassMeta): CodeBlock { - val fields = ArrayList() - val typeMask = meta.type.findAnnotation(LoggingTypes.mask) - for (field in meta.fields) { - val mask = field.mask - if (mask != null) { - fields.add(CodeBlock.of("%S, %T.mask(%L)", field.jsonName, LoggingTypes.maskingFieldMeta, maskRuleCode(mask, typeMask))) - continue - } - val nestedType = nestedType(field.type) ?: continue - val code = when { - field.type.isCollection() -> CodeBlock.of("%S, %T.collection(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) - field.type.isMap() -> CodeBlock.of("%S, %T.mapValue(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) - else -> CodeBlock.of("%S, %T.object(%T::class.java)", field.jsonName, LoggingTypes.maskingFieldMeta, nestedType.toClassName()) - } - fields.add(code) - } - if (fields.isEmpty()) { - return CodeBlock.of("java.util.Map.of<%T, %T>()", String::class.asClassName(), LoggingTypes.maskingFieldMeta) - } - - val code = CodeBlock.builder().add("java.util.Map.ofEntries<%T, %T>(\n", String::class.asClassName(), LoggingTypes.maskingFieldMeta).indent() - for (i in fields.indices) { - code.add("java.util.Map.entry<%T, %T>(%L)", String::class.asClassName(), LoggingTypes.maskingFieldMeta, fields[i]) - if (i < fields.size - 1) { - code.add(",\n") - } - } - return code.unindent().add("\n)").build() - } - - private fun maskRuleCode(mask: KSAnnotation, typeMask: KSAnnotation?): CodeBlock { - val value = mask.findValueNoDefault("value") - ?: typeMask?.findValue("value") - ?: "***" - val mode = mask.findValueNoDefault("mode") - ?: typeMask?.findValue("mode") - val modeName = mode?.simpleName?.asString() ?: "FULL" - val keep = mask.findValueNoDefault("keep") - ?: typeMask?.findValue("keep") - ?: 4 - return CodeBlock.of("%T.replacement(%S, %T.%L, %L)", LoggingTypes.maskRule, value, LoggingTypes.maskMode, modeName, keep) - } - - private fun visit(type: KSClassDeclaration, visited: MutableMap) { - val key = type.qualifiedName!!.asString() - if (visited.containsKey(key) || !type.isJsonOrMasked()) { - return - } - val meta = parse(type) - visited[key] = meta - for (field in meta.fields) { - if (field.mask != null) { - continue - } - for (nested in nestedTypes(field.type)) { - visit(nested, visited) - } - } - } - - private fun parse(type: KSClassDeclaration): MaskingClassMeta { - val nameConverter = type.getNameConverter() - val fields = ArrayList() - for (property in type.getAllProperties()) { - if (property.isAnnotationPresent(LoggingTypes.jsonSkip)) { - continue - } - val constructorParameter = type.primaryConstructor?.parameters?.firstOrNull { it.name?.asString() == property.simpleName.asString() } - val jsonField = property.findAnnotation(LoggingTypes.jsonField) ?: constructorParameter?.findAnnotation(LoggingTypes.jsonField) - val jsonName = jsonField?.findValueNoDefault("value")?.takeIf { it.isNotBlank() } - ?: nameConverter?.convert(property.simpleName.asString()) - ?: property.simpleName.asString() - val mask = property.findAnnotation(LoggingTypes.mask) ?: constructorParameter?.findAnnotation(LoggingTypes.mask) - fields.add(MaskingField(jsonName, property.type.resolve(), mask)) - } - return MaskingClassMeta(type, fields) - } - - private fun nestedType(type: KSType): KSClassDeclaration? { - val nested = nestedTypes(type) - return if (nested.size == 1) nested[0] else null - } - - private fun nestedTypes(type: KSType): List { - if (type.isCollection()) { - val argument = type.arguments.firstOrNull()?.type?.resolve() ?: return emptyList() - return nestedTypes(argument) - } - if (type.isMap()) { - val argument = type.arguments.getOrNull(1)?.type?.resolve() ?: return emptyList() - return nestedTypes(argument) - } - val declaration = type.declaration - if (declaration !is KSClassDeclaration || !declaration.isJsonOrMasked()) { - return emptyList() - } - return listOf(declaration) - } - - private fun KSClassDeclaration.isJsonOrMasked(): Boolean { - return this.isAnnotationPresent(LoggingTypes.json) - || this.isAnnotationPresent(LoggingTypes.jsonWriter) - || this.isAnnotationPresent(LoggingTypes.mask) - } -} - -private object LoggingTypes { - val mask = ClassName("io.koraframework.logging.common.annotation", "Mask") - val maskMode = mask.nestedClass("Mode") - val json = ClassName("io.koraframework.json.common.annotation", "Json") - val jsonWriter = ClassName("io.koraframework.json.common.annotation", "JsonWriter") - val jsonField = ClassName("io.koraframework.json.common.annotation", "JsonField") - val jsonSkip = ClassName("io.koraframework.json.common.annotation", "JsonSkip") - val maskingMetadata = ClassName("io.koraframework.logging.common.masking", "MaskingMetadata") - val maskingClassMeta = ClassName("io.koraframework.logging.common.masking", "MaskingClassMeta") - val maskingFieldMeta = ClassName("io.koraframework.logging.common.masking", "MaskingFieldMeta") - val maskRule = ClassName("io.koraframework.logging.common.masking", "MaskRule") -} - -private data class MaskingClassMeta(val type: KSClassDeclaration, val fields: List) - -private data class MaskingField(val jsonName: String, val type: KSType, val mask: KSAnnotation?) diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt deleted file mode 100644 index 578c5258b..000000000 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingMetadataSymbolProcessorProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.koraframework.logging.symbol.processor - -import com.google.devtools.ksp.processing.SymbolProcessorEnvironment -import com.google.devtools.ksp.processing.SymbolProcessorProvider - -class MaskingMetadataSymbolProcessorProvider : SymbolProcessorProvider { - override fun create(environment: SymbolProcessorEnvironment) = MaskingMetadataSymbolProcessor(environment) -} diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt index 2517bed98..a23381e6e 100644 --- a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/aop/LogKoraAspect.kt @@ -5,6 +5,7 @@ import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.ksp.toTypeName @@ -19,6 +20,7 @@ import io.koraframework.ksp.common.FunctionUtils.isVoid import io.koraframework.ksp.common.KotlinPoetUtils.controlFlow import io.koraframework.ksp.common.KotlinPoetUtils.nextControlFlow import io.koraframework.ksp.common.MappingData +import io.koraframework.ksp.common.doesImplement import io.koraframework.ksp.common.parseMappingData class LogKoraAspect : KoraAspect { @@ -44,8 +46,12 @@ class LogKoraAspect : KoraAspect { val logOffAnnotation = logAnnotation.nestedClass("off") val logResultAnnotation = logAnnotation.nestedClass("result") val maskAnnotation = ClassName("io.koraframework.logging.common.annotation", "Mask") + val jsonAnnotation = ClassName("io.koraframework.json.common.annotation", "Json") val structuredArgument = ClassName("io.koraframework.logging.common.arg", "StructuredArgument") val structuredArgumentMapper = ClassName("io.koraframework.logging.common.arg", "StructuredArgumentMapper") + val maskingStructuredArgumentMapper = ClassName("io.koraframework.logging.common.arg", "MaskedStructuredArgumentMapper") + val maskingRules = ClassName("io.koraframework.logging.common.masking", "MaskingRules") + val jsonWriter = ClassName("io.koraframework.json.common", "JsonWriter") val iLoggerFactoryType = ClassName("org.slf4j", "ILoggerFactory") val loggerType = ClassName("org.slf4j", "Logger") } @@ -117,23 +123,13 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.structuredArgumentMapping() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) - + val mapper = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } else { controlFlow("if (%N.%N())", loggerName, level.isEnabledMethod()) { parameters.forEach { parameter -> - val mapping = parameter.structuredArgumentMapping() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) - + val mapper = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } @@ -171,11 +167,7 @@ class LogKoraAspect : KoraAspect { controlFlow("if (%N.%N())", loggerName, resultLogLevel.isEnabledMethod()) { controlFlow("val %L = %T.marker(%S) { gen -> ", DATA_OUT_FIELD_NAME, structuredArgument, DATA_PARAMETER_NAME) { addStatement("gen.writeStartObject()") - val mapping = function.structuredArgumentMapping() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) mapping.parameterized(function.returnType!!.resolve().toTypeName()) else it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(function.returnType!!.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) + val mapper = function.structuredArgumentMapperField(aspectContext, function.returnType!!.resolve().toTypeName()) writeWithMapper(mapper, OUT_PARAMETER_NAME, RESULT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -242,23 +234,13 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.structuredArgumentMapping() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) - + val mapper = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } else { controlFlow("if (%N.%N())", loggerName, level.isEnabledMethod()) { parameters.forEach { parameter -> - val mapping = parameter.structuredArgumentMapping() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) mapping.parameterized(parameter.type.resolve().toTypeName()) else it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(parameter.type.resolve().toTypeName()) - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) - + val mapper = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } @@ -311,16 +293,8 @@ class LogKoraAspect : KoraAspect { controlFlow("%L = %L.%M { %L -> ", RESULT_FIELD_NAME, RESULT_FIELD_NAME, MemberName("kotlinx.coroutines.flow", "onEach"), ELEMENT_FIELD_NAME) { controlFlow("val %L = %T.marker(%S) { gen -> ", DATA_OUT_FIELD_NAME, structuredArgument, DATA_PARAMETER_NAME) { addStatement("gen.writeStartObject()") - val mapping = function.structuredArgumentMapping() val flowGeneric = function.returnType!!.resolve().arguments[0].type!!.resolve() - val mapperType = mapping?.mapper?.let { - if (mapping.isGeneric()) - mapping.parameterized(flowGeneric.toTypeName()) - else - it.toTypeName() - } ?: structuredArgumentMapper.parameterizedBy(flowGeneric.toTypeName()) - - val mapper = aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) + val mapper = function.structuredArgumentMapperField(aspectContext, flowGeneric.toTypeName()) writeWithMapper(mapper, OUT_PARAMETER_NAME, ELEMENT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -365,15 +339,54 @@ class LogKoraAspect : KoraAspect { ?.let { Level.valueOf(it) } } - private fun KSAnnotated.structuredArgumentMapping(): MappingData? { - val mapping = this.parseMappingData().getMapping(structuredArgumentMapper) - if (mapping != null) { - return mapping + private fun KSAnnotated.structuredArgumentMapperInterface(): ClassName { + return if (this.isAnnotationPresent(maskAnnotation)) maskingStructuredArgumentMapper else structuredArgumentMapper + } + + private fun KSAnnotated.structuredArgumentMapping(mapperInterface: ClassName): MappingData? { + return this.parseMappingData().getMapping(mapperInterface) + } + + private fun KSAnnotated.structuredArgumentMapperField(aspectContext: KoraAspect.AspectContext, valueType: TypeName): String { + val mapperInterface = this.structuredArgumentMapperInterface() + val mapping = this.structuredArgumentMapping(mapperInterface) + val rulesMapping = if (this.isAnnotationPresent(maskAnnotation)) this.maskingRulesMapping() else null + if (rulesMapping?.mapper != null && mapping?.mapper == null) { + return this.maskedStructuredArgumentMapperField(aspectContext, valueType, rulesMapping) } - if (this.isAnnotationPresent(maskAnnotation)) { - return MappingData(null, maskAnnotation.canonicalName) + + val mapperType = mapping?.mapper?.let { + if (mapping.isGeneric()) mapping.parameterized(valueType) else it.toTypeName() + } ?: mapperInterface.parameterizedBy(valueType) + return aspectContext.fieldFactory.constructorParam(mapperType.copy(true), listOfNotNull(mapping?.toTagAnnotation())) + } + + private fun KSAnnotated.maskedStructuredArgumentMapperField( + aspectContext: KoraAspect.AspectContext, + valueType: TypeName, + rulesMapping: MappingData + ): String { + val writer = aspectContext.fieldFactory.constructorParam(jsonWriter.parameterizedBy(valueType), emptyList()) + val rulesType = if (rulesMapping.isGeneric()) { + rulesMapping.parameterized(valueType) + } else { + rulesMapping.mapper!!.toTypeName() } - return null + val rules = aspectContext.fieldFactory.constructorParam(rulesType, listOfNotNull(rulesMapping.toTagAnnotation())) + return aspectContext.fieldFactory.constructorInitialized( + maskingStructuredArgumentMapper.parameterizedBy(valueType), + CodeBlock.of("%T(%N, %N, %L)", maskingStructuredArgumentMapper, writer, rules, this.isAnnotationPresent(jsonAnnotation)) + ) + } + + private fun KSAnnotated.maskingRulesMapping(): MappingData? { + val mappingData = this.parseMappingData() + return mappingData.mapperClasses + .firstOrNull { mapper -> + val declaration = mapper.declaration + declaration is com.google.devtools.ksp.symbol.KSClassDeclaration && declaration.doesImplement(maskingRules) + } + ?.let { MappingData(it, mappingData.tag) } } private fun KSFunctionDeclaration.inLogLevel(): Level? { diff --git a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt index 426e4834b..059caa94e 100644 --- a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt +++ b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/AbstractLogAspectTest.kt @@ -37,6 +37,12 @@ abstract class AbstractLogAspectTest : AbstractSymbolProcessorTest() { return super.commonImports() + """ import io.koraframework.logging.common.annotation.Log import io.koraframework.logging.common.annotation.Mask + import io.koraframework.logging.common.masking.MaskingFull + import io.koraframework.logging.common.masking.MaskingKeepFirst + import io.koraframework.logging.common.masking.MaskingKeepLast + import io.koraframework.logging.common.masking.MaskingRules + import io.koraframework.logging.common.masking.MaskingStrategy + import io.koraframework.common.annotation.Mapping import io.koraframework.json.common.annotation.Json import io.koraframework.json.common.annotation.JsonField import org.slf4j.event.Level.* diff --git a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt index 9a03ea99e..c996ed887 100644 --- a/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt +++ b/logging/logging-symbol-processor/src/test/kotlin/io/koraframework/logging/symbol/processor/aop/LogAspectTest.kt @@ -12,8 +12,12 @@ import io.koraframework.json.common.writer.MapJsonWriter import io.koraframework.json.ksp.JsonSymbolProcessorProvider import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper -import io.koraframework.logging.common.masking.MaskingMetadata -import io.koraframework.logging.symbol.processor.MaskingMetadataSymbolProcessorProvider +import io.koraframework.logging.common.masking.MaskingFull +import io.koraframework.logging.common.masking.MaskingKeepLast +import io.koraframework.logging.common.masking.MaskingRules +import io.koraframework.logging.symbol.processor.MaskingRulesSymbolProcessorProvider +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy import java.util.* class LogAspectTest : AbstractLogAspectTest() { @@ -390,9 +394,9 @@ class LogAspectTest : AbstractLogAspectTest() { } @Test - fun testMaskingMetadataSupportsNestedGenericContainers() { + fun testMaskingRulesSupportsNestedGenericContainers() { compile0( - listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), """ @Mask @Json @@ -417,8 +421,8 @@ class LogAspectTest : AbstractLogAspectTest() { val nestedListWriter = ListJsonWriter(listWriter) val nestedMapWriter = MapJsonWriter(listWriter) val userWriter = new("\$User_JsonWriter", nestedListWriter, nestedMapWriter) as JsonWriter - val metadata = new("\$User_MaskingMetadata") as MaskingMetadata - val mapper = MaskedStructuredArgumentMapper(userWriter, metadata) + val rules = maskingRules("\$User_MaskingRulesModule", MaskingFull()) + val mapper = MaskedStructuredArgumentMapper(userWriter, rules) val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) Mockito.verify(factory).getLogger(testPackage() + ".Target.test") @@ -439,7 +443,7 @@ class LogAspectTest : AbstractLogAspectTest() { @Test fun testLogResultWithJsonMapperTag() { compile0( - listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), """ @Json data class TestRecord(val value: String) @@ -473,18 +477,19 @@ class LogAspectTest : AbstractLogAspectTest() { } @Test - fun testLogResultWithMaskedMapperTag() { + fun testLogResultWithMaskingMapper() { compile0( - listOf(JsonSymbolProcessorProvider(), MaskingMetadataSymbolProcessorProvider(), AopSymbolProcessorProvider()), + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), """ @Mask @Json - data class User(val name: String, @Mask(mode = Mask.Mode.KEEP_LAST, keep = 2) val token: String) + data class User(val name: String, @Mask(MaskingKeepLast::class) val token: String) """.trimIndent(), """ open class Target { @Log.out @Mask + @Json open fun test(): User { return User("user", "secret") } @@ -494,8 +499,8 @@ class LogAspectTest : AbstractLogAspectTest() { compileResult.assertSuccess() val writer = new("\$User_JsonWriter") as JsonWriter - val metadata = new("\$User_MaskingMetadata") as MaskingMetadata - val mapper = MaskedStructuredArgumentMapper(writer, metadata) + val rules = maskingRules("\$User_MaskingRulesModule", MaskingKeepLast("###", 2)) + val mapper = MaskedStructuredArgumentMapper(writer, rules) val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) Mockito.verify(factory).getLogger(testPackage() + ".Target.test") @@ -507,7 +512,143 @@ class LogAspectTest : AbstractLogAspectTest() { o.verify(log).isDebugEnabled o.verify(log).info(outData.capture(), ArgumentMatchers.eq("<")) o.verifyNoMoreInteractions() - verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"***et\"}}") + verifyOutJson("{\"out\":{\"name\":\"user\",\"token\":\"###et\"}}") + } + + @Test + @Suppress("UNCHECKED_CAST") + fun testLogResultWithCustomMaskingRulesMapping() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Mask + @Json + data class User(val name: String, @Mask val token: String) + """.trimIndent(), + """ + class CustomRules : MaskingRules( + User::class.java, + mapOf("token" to MaskingStrategy { value -> "rules-${'$'}value" }) + ) + """.trimIndent(), + """ + open class Target { + @Log.out + @Mask + @Mapping(CustomRules::class) + open fun test(): User { + return User("user", "secret") + } + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$User_JsonWriter") as JsonWriter + val rules = new("CustomRules") + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, writer, rules)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + reset(log, Level.DEBUG) + aopProxy.invoke("test") + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(outData.capture(), ArgumentMatchers.eq("<")) + o.verifyNoMoreInteractions() + verifyOutJson("{\"out\":\"{\\\"name\\\":\\\"user\\\",\\\"token\\\":\\\"rules-secret\\\"}\"}") + } + + @Test + fun testLogArgsWithCustomMaskingStrategy() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + class CustomMaskingStrategy : MaskingStrategy { + override fun mask(value: Any?): String = "custom-${'$'}value" + } + """.trimIndent(), + """ + @Mask + @Json + data class User(val name: String, @Mask(CustomMaskingStrategy::class) val token: String) + """.trimIndent(), + """ + open class Target { + @Log.`in` + open fun test(@Mask @Json arg1: User) {} + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$User_JsonWriter") as JsonWriter + val rules = maskingRules("\$User_MaskingRulesModule", new("CustomMaskingStrategy")) + val mapper = MaskedStructuredArgumentMapper(writer, rules) + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, mapper)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + reset(log, Level.DEBUG) + aopProxy.invoke("test", new("User", "user", "secret")) + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(inData.capture(), ArgumentMatchers.eq(">")) + o.verifyNoMoreInteractions() + verifyInJson("{\"arg1\":{\"name\":\"user\",\"token\":\"custom-secret\"}}") + } + + @Test + @Suppress("UNCHECKED_CAST") + fun testLogArgsWithCustomMaskingRulesMapping() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Mask + @Json + data class User(val name: String, @Mask val token: String) + """.trimIndent(), + """ + class CustomRules : MaskingRules( + User::class.java, + mapOf("token" to MaskingStrategy { value -> "rules-${'$'}value" }) + ) + """.trimIndent(), + """ + open class Target { + @Log.`in` + open fun test(@Mask @Mapping(CustomRules::class) arg1: User) {} + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$User_JsonWriter") as JsonWriter + val rules = new("CustomRules") + val aopProxy = TestObject(loadClass("\$Target__AopProxy").kotlin, new("\$Target__AopProxy", factory, writer, rules)) + + Mockito.verify(factory).getLogger(testPackage() + ".Target.test") + val log = Objects.requireNonNull(loggers[testPackage() + ".Target.test"])!! + + reset(log, Level.DEBUG) + aopProxy.invoke("test", new("User", "user", "secret")) + val o = Mockito.inOrder(log) + o.verify(log).isDebugEnabled + o.verify(log).info(inData.capture(), ArgumentMatchers.eq(">")) + o.verifyNoMoreInteractions() + verifyInJson("{\"arg1\":\"{\\\"name\\\":\\\"user\\\",\\\"token\\\":\\\"rules-secret\\\"}\"}") + } + + @Suppress("UNCHECKED_CAST") + private fun maskingRules(moduleName: String, vararg args: Any): MaskingRules { + val module = loadClass(moduleName) + val proxy = Proxy.newProxyInstance(module.classLoader, arrayOf(module)) { p, method, methodArgs -> + InvocationHandler.invokeDefault(p, method, *(methodArgs ?: emptyArray())) + } + val method = module.methods.first { it.returnType == MaskingRules::class.java } + return method.invoke(proxy, *args) as MaskingRules } } From d0a835906c029d194a72af60fac437771016c03b Mon Sep 17 00:00:00 2001 From: Anton Kurako Date: Thu, 23 Jul 2026 15:14:40 +0300 Subject: [PATCH 3/3] Added masked log argument rules with custom mapping and structured modes Added declarative masking rules for logged values, including generated default MaskingRules components and custom @Mapping-selected rules for @Mask parameters and results. - Added MaskingStrategy-based masking with built-in full, keep-first, and keep-last strategies. - Added recursive path-based MaskingRules generation for Java AP and KSP, including nested collections and map value wildcards. - Improved masked logging so @Mask writes masked JSON as a string by default, while @Mask @Json keeps structured JSON output. - Added support for custom MaskingRules implementations selected through @Mapping on logged arguments and result values. --- .../processor/MaskingRulesProcessor.java | 233 ++++++++++++++++++ .../processor/MaskingRulesSymbolProcessor.kt | 205 +++++++++++++++ .../MaskingRulesSymbolProcessorProvider.kt | 8 + 3 files changed, 446 insertions(+) create mode 100644 logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingRulesProcessor.java create mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessor.kt create mode 100644 logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessorProvider.kt diff --git a/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingRulesProcessor.java b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingRulesProcessor.java new file mode 100644 index 000000000..9406042ee --- /dev/null +++ b/logging/logging-annotation-processor/src/main/java/io/koraframework/logging/annotation/processor/MaskingRulesProcessor.java @@ -0,0 +1,233 @@ +package io.koraframework.logging.annotation.processor; + +import com.palantir.javapoet.*; +import io.koraframework.annotation.processor.common.*; +import io.koraframework.logging.annotation.processor.aop.LogAspectClassNames; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.*; +import javax.lang.model.type.*; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import java.util.*; + +public final class MaskingRulesProcessor { + + private final ProcessingEnvironment env; + private final Elements elements; + private final Types types; + + public MaskingRulesProcessor(ProcessingEnvironment env) { + this.env = env; + this.elements = env.getElementUtils(); + this.types = env.getTypeUtils(); + } + + public void generate(TypeElement root) { + var packageName = this.elements.getPackageOf(root).getQualifiedName().toString(); + var className = metadataName(root); + if (this.elements.getTypeElement(packageName + "." + className) != null) { + return; + } + + var rules = new ArrayList(); + this.visit(root, new ArrayList<>(), new HashSet<>(), rules); + var strategies = this.strategies(rules); + + var rootType = TypeName.get(root.asType()); + var rulesType = ParameterizedTypeName.get(LogAspectClassNames.maskingRules, rootType); + var typeBuilder = TypeSpec.interfaceBuilder(className) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(CommonClassNames.module) + .addAnnotation(AnnotationUtils.generated(MaskingRulesProcessor.class)) + .addMethod(this.factoryMethod(root, rulesType, rules, strategies)); + + CommonUtils.safeWriteTo(this.env, JavaFile.builder(packageName, typeBuilder.build()).build()); + } + + private MethodSpec factoryMethod(TypeElement root, TypeName rulesType, List rules, Map strategies) { + var method = MethodSpec.methodBuilder(CommonUtils.decapitalize(root.getSimpleName().toString()) + "MaskingRules") + .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addAnnotation(CommonClassNames.defaultComponent) + .returns(rulesType); + for (var strategy : strategies.values()) { + method.addParameter(TypeName.get(strategy.type()), strategy.fieldName()); + } + method.addStatement("return $L", this.rulesCode(root, rules, strategies)); + return method.build(); + } + + private CodeBlock rulesCode(TypeElement root, List rules, Map strategies) { + var code = CodeBlock.builder() + .add("$T.builder($T.class)", LogAspectClassNames.maskingRules, TypeName.get(root.asType())); + for (var rule : rules) { + var strategy = strategies.get(rule.strategy().toString()); + code.add("\n.mask($S, $N)", String.join(".", rule.path()), strategy.fieldName()); + } + return code.add("\n.build()").build(); + } + + private Map strategies(List rules) { + var strategies = new LinkedHashMap(); + for (var rule : rules) { + strategies.computeIfAbsent(rule.strategy().toString(), k -> new StrategyMeta(rule.strategy(), "strategy" + strategies.size())); + } + return strategies; + } + + private void visit(TypeElement type, List path, Set branch, List rules) { + if (!this.isJsonOrMasked(type)) { + return; + } + var key = type.getQualifiedName().toString(); + if (!branch.add(key)) { + return; + } + + var typeMask = findMask(type); + for (var field : this.parse(type)) { + var fieldPath = new ArrayList<>(path); + fieldPath.add(field.jsonName()); + if (field.mask() != null) { + rules.add(new MaskingRuleMeta(fieldPath, this.maskStrategy(field.mask(), typeMask), false)); + continue; + } + this.visitFieldType(field.type(), fieldPath, branch, rules); + } + branch.remove(key); + } + + private void visitFieldType(TypeMirror type, List path, Set branch, List rules) { + if (type.getKind() == TypeKind.ARRAY && type instanceof ArrayType arrayType) { + this.visitFieldType(arrayType.getComponentType(), path, branch, rules); + return; + } + if (type.getKind() != TypeKind.DECLARED || !(type instanceof DeclaredType declaredType)) { + return; + } + if (CommonUtils.isCollection(type)) { + if (!declaredType.getTypeArguments().isEmpty()) { + this.visitFieldType(declaredType.getTypeArguments().get(0), path, branch, rules); + } + return; + } + if (CommonUtils.isMap(type)) { + if (declaredType.getTypeArguments().size() >= 2) { + var valuePath = new ArrayList<>(path); + valuePath.add("*"); + this.visitFieldType(declaredType.getTypeArguments().get(1), valuePath, branch, rules); + } + return; + } + + var element = this.types.asElement(type); + if (element instanceof TypeElement typeElement) { + this.visit(typeElement, path, branch, rules); + } + } + + private TypeMirror maskStrategy(AnnotationMirror mask, AnnotationMirror typeMask) { + var strategyType = AnnotationUtils.parseAnnotationValueWithoutDefault(mask, "value"); + if (strategyType != null) { + return strategyType; + } + if (typeMask != null) { + return AnnotationUtils.parseAnnotationValue(this.elements, typeMask, "value"); + } + return this.elements.getTypeElement(LogAspectClassNames.maskingFull.canonicalName()).asType(); + } + + private List parse(TypeElement type) { + var fields = new ArrayList(); + var nameConverter = CommonUtils.getNameConverter(type); + if (type.getKind() == ElementKind.RECORD) { + for (var enclosed : type.getEnclosedElements()) { + if (!(enclosed instanceof RecordComponentElement component)) { + continue; + } + if (AnnotationUtils.findAnnotation(component, LogAspectClassNames.jsonSkip) != null) { + continue; + } + var accessor = this.findRecordAccessor(type, component); + var jsonName = this.jsonName(component, nameConverter); + var mask = findMask(component, accessor); + fields.add(new MaskingField(jsonName, component.asType(), mask)); + } + } else { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD || !(enclosed instanceof VariableElement field)) { + continue; + } + if (field.getModifiers().contains(Modifier.STATIC)) { + continue; + } + if (AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonSkip) != null) { + continue; + } + var jsonName = this.jsonName(field, nameConverter); + var accessor = this.findAccessor(type, field); + var mask = accessor == null ? findMask(field) : findMask(field, accessor); + fields.add(new MaskingField(jsonName, field.asType(), mask)); + } + } + return fields; + } + + private String jsonName(Element field, CommonUtils.NameConverter nameConverter) { + var jsonField = AnnotationUtils.findAnnotation(field, LogAspectClassNames.jsonField); + if (jsonField != null) { + var value = AnnotationUtils.parseAnnotationValueWithoutDefault(jsonField, "value"); + if (value != null && !value.isBlank()) { + return value; + } + } + var name = field.getSimpleName().toString(); + return nameConverter == null ? name : nameConverter.convert(name); + } + + private ExecutableElement findRecordAccessor(TypeElement type, RecordComponentElement component) { + return CommonUtils.findMethods(type, m -> m.contains(Modifier.PUBLIC)) + .stream() + .filter(m -> m.getParameters().isEmpty()) + .filter(m -> m.getSimpleName().contentEquals(component.getSimpleName())) + .findFirst() + .orElse(null); + } + + private ExecutableElement findAccessor(TypeElement type, VariableElement field) { + var name = field.getSimpleName().toString(); + var capitalized = CommonUtils.capitalize(name); + return CommonUtils.findMethods(type, m -> m.contains(Modifier.PUBLIC)) + .stream() + .filter(m -> m.getParameters().isEmpty()) + .filter(m -> m.getSimpleName().contentEquals(name) || m.getSimpleName().contentEquals("get" + capitalized)) + .findFirst() + .orElse(null); + } + + private boolean isJsonOrMasked(TypeElement typeElement) { + return AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.json) != null + || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.jsonWriter) != null + || AnnotationUtils.findAnnotation(typeElement, LogAspectClassNames.mask) != null; + } + + private static AnnotationMirror findMask(Element... elements) { + for (var element : elements) { + var mask = AnnotationUtils.findAnnotation(element, LogAspectClassNames.mask); + if (mask != null) { + return mask; + } + } + return null; + } + + public static String metadataName(TypeElement type) { + return NameUtils.generatedType(type, "MaskingRulesModule"); + } + + private record MaskingField(String jsonName, TypeMirror type, AnnotationMirror mask) {} + + private record MaskingRuleMeta(List path, TypeMirror strategy, boolean fieldOnly) {} + + private record StrategyMeta(TypeMirror type, String fieldName) {} +} diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessor.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessor.kt new file mode 100644 index 000000000..73d6d9130 --- /dev/null +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessor.kt @@ -0,0 +1,205 @@ +package io.koraframework.logging.symbol.processor + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.writeTo +import io.koraframework.ksp.common.AnnotationUtils.findAnnotation +import io.koraframework.ksp.common.AnnotationUtils.findValue +import io.koraframework.ksp.common.AnnotationUtils.findValueNoDefault +import io.koraframework.ksp.common.AnnotationUtils.isAnnotationPresent +import io.koraframework.ksp.common.BaseSymbolProcessor +import io.koraframework.ksp.common.CommonClassNames +import io.koraframework.ksp.common.CommonClassNames.isCollection +import io.koraframework.ksp.common.CommonClassNames.isMap +import io.koraframework.ksp.common.KspCommonUtils.addOriginatingKSFile +import io.koraframework.ksp.common.KspCommonUtils.generated +import io.koraframework.ksp.common.KspCommonUtils.getNameConverter +import io.koraframework.ksp.common.generatedClassName + +class MaskingRulesSymbolProcessor( + environment: SymbolProcessorEnvironment +) : BaseSymbolProcessor(environment) { + + private val processed = HashSet() + private val codeGenerator: CodeGenerator = environment.codeGenerator + + override fun processRound(resolver: Resolver): List { + val delayed = ArrayList() + for (symbol in resolver.getSymbolsWithAnnotation(LoggingTypes.mask.canonicalName)) { + if (!symbol.validateAll()) { + delayed.add(symbol) + continue + } + if (symbol !is KSClassDeclaration) { + continue + } + if (symbol.modifiers.contains(Modifier.ABSTRACT)) { + kspLogger.error("Abstract classes can't be annotated with @Mask", symbol) + continue + } + if (!processed.add(symbol.qualifiedName!!.asString())) { + continue + } + MaskingRulesGenerator(resolver, codeGenerator).generate(symbol) + } + return delayed + } +} + +private class MaskingRulesGenerator( + private val resolver: Resolver, + private val codeGenerator: CodeGenerator +) { + fun generate(root: KSClassDeclaration) { + val packageName = root.packageName.asString() + val className = root.generatedClassName("MaskingRulesModule") + if (resolver.getClassDeclarationByName(resolver.getKSNameFromString("$packageName.$className")) != null) { + return + } + + val rules = ArrayList() + visit(root, emptyList(), HashSet(), rules) + val strategies = strategies(rules) + + val factoryMethod = FunSpec.builder(root.simpleName.asString().replaceFirstChar { it.lowercase() } + "MaskingRules") + .addModifiers(KModifier.PUBLIC) + .addAnnotation(CommonClassNames.defaultComponent) + .returns(LoggingTypes.maskingRules.parameterizedBy(root.toClassName())) + for (strategy in strategies.values) { + factoryMethod.addParameter(strategy.parameterName, strategy.type.toClassName()) + } + factoryMethod.addStatement("return %L", rulesCode(root, rules, strategies)) + + val type = TypeSpec.interfaceBuilder(className) + .addModifiers(KModifier.PUBLIC) + .addAnnotation(CommonClassNames.module) + .generated(MaskingRulesGenerator::class) + .addOriginatingKSFile(root) + .addFunction(factoryMethod.build()) + .build() + + FileSpec.builder(packageName, className) + .addType(type) + .build() + .writeTo(codeGenerator = codeGenerator, aggregating = false) + } + + private fun rulesCode(root: KSClassDeclaration, rules: List, strategies: Map): CodeBlock { + val code = CodeBlock.builder().add("%T.builder(%T::class.java)", LoggingTypes.maskingRules, root.toClassName()) + for (rule in rules) { + val strategy = strategies[rule.strategy.qualifiedName!!.asString()]!! + code.add("\n.mask(%S, %N)", rule.path.joinToString("."), strategy.parameterName) + } + return code.add("\n.build()").build() + } + + private fun strategies(rules: List): Map { + val strategies = linkedMapOf() + for (rule in rules) { + val key = rule.strategy.qualifiedName!!.asString() + strategies.computeIfAbsent(key) { + StrategyMeta(rule.strategy, "strategy${strategies.size}") + } + } + return strategies + } + + private fun visit(type: KSClassDeclaration, path: List, branch: MutableSet, rules: MutableList) { + if (!type.isJsonOrMasked()) { + return + } + val key = type.qualifiedName!!.asString() + if (!branch.add(key)) { + return + } + val typeMask = type.findAnnotation(LoggingTypes.mask) + for (field in parse(type)) { + val fieldPath = path + field.jsonName + val mask = field.mask + if (mask != null) { + rules.add(MaskingRuleMeta(fieldPath, maskStrategy(mask, typeMask), false)) + continue + } + visitFieldType(field.type, fieldPath, branch, rules) + } + branch.remove(key) + } + + private fun visitFieldType(type: KSType, path: List, branch: MutableSet, rules: MutableList) { + if (type.isCollection()) { + val argument = type.arguments.firstOrNull()?.type?.resolve() ?: return + visitFieldType(argument, path, branch, rules) + return + } + if (type.isMap()) { + val argument = type.arguments.getOrNull(1)?.type?.resolve() ?: return + visitFieldType(argument, path + "*", branch, rules) + return + } + val declaration = type.declaration + if (declaration is KSClassDeclaration) { + visit(declaration, path, branch, rules) + } + } + + private fun maskStrategy(mask: KSAnnotation, typeMask: KSAnnotation?): KSClassDeclaration { + val strategy = mask.findValueNoDefault("value") + ?: typeMask?.findValue("value") + ?: resolver.getClassDeclarationByName(resolver.getKSNameFromString(LoggingTypes.maskingFull.canonicalName))!!.asStarProjectedType() + return strategy.declaration as KSClassDeclaration + } + + private fun parse(type: KSClassDeclaration): List { + val nameConverter = type.getNameConverter() + val fields = ArrayList() + for (property in type.getAllProperties()) { + if (property.isAnnotationPresent(LoggingTypes.jsonSkip)) { + continue + } + val constructorParameter = type.primaryConstructor?.parameters?.firstOrNull { it.name?.asString() == property.simpleName.asString() } + val jsonField = property.findAnnotation(LoggingTypes.jsonField) ?: constructorParameter?.findAnnotation(LoggingTypes.jsonField) + val jsonName = jsonField?.findValueNoDefault("value")?.takeIf { it.isNotBlank() } + ?: nameConverter?.convert(property.simpleName.asString()) + ?: property.simpleName.asString() + val mask = property.findAnnotation(LoggingTypes.mask) ?: constructorParameter?.findAnnotation(LoggingTypes.mask) + fields.add(MaskingField(jsonName, property.type.resolve(), mask)) + } + return fields + } + + private fun KSClassDeclaration.isJsonOrMasked(): Boolean { + return this.isAnnotationPresent(LoggingTypes.json) + || this.isAnnotationPresent(LoggingTypes.jsonWriter) + || this.isAnnotationPresent(LoggingTypes.mask) + } +} + +private object LoggingTypes { + val mask = ClassName("io.koraframework.logging.common.annotation", "Mask") + val json = ClassName("io.koraframework.json.common.annotation", "Json") + val jsonWriter = ClassName("io.koraframework.json.common.annotation", "JsonWriter") + val jsonField = ClassName("io.koraframework.json.common.annotation", "JsonField") + val jsonSkip = ClassName("io.koraframework.json.common.annotation", "JsonSkip") + val maskingRules = ClassName("io.koraframework.logging.common.masking", "MaskingRules") + val maskingFull = ClassName("io.koraframework.logging.common.masking", "MaskingFull") +} + +private data class MaskingField(val jsonName: String, val type: KSType, val mask: KSAnnotation?) + +private data class MaskingRuleMeta(val path: List, val strategy: KSClassDeclaration, val fieldOnly: Boolean) + +private data class StrategyMeta(val type: KSClassDeclaration, val parameterName: String) diff --git a/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessorProvider.kt b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessorProvider.kt new file mode 100644 index 000000000..2e0ce835a --- /dev/null +++ b/logging/logging-symbol-processor/src/main/kotlin/io/koraframework/logging/symbol/processor/MaskingRulesSymbolProcessorProvider.kt @@ -0,0 +1,8 @@ +package io.koraframework.logging.symbol.processor + +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +class MaskingRulesSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment) = MaskingRulesSymbolProcessor(environment) +}