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..a4533048e --- /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 MaskingRulesProcessor maskingRulesProcessor; + + @Override + public Set getSupportedAnnotationClassNames() { + return Set.of(LogAspectClassNames.mask); + } + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.maskingRulesProcessor = new MaskingRulesProcessor(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.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/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-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 77% 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..547b34989 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,17 @@ 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.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.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,14 +88,7 @@ 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 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() - ); + 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) @@ -172,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 = CommonUtils.parseMapping(executableElement).getMapping(structuredArgumentMapper); - 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() - ); + 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) @@ -309,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 = CommonUtils.parseMapping(param).getMapping(structuredArgumentMapper); - 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() - ); + 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()); @@ -339,4 +322,77 @@ private CodeBlock.Builder ifLogLevelEnabled(CodeBlock.Builder cb, String loggerF cb.add("$<\n}\n"); return cb; } + + 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); + } + + 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 new file mode 100644 index 000000000..4a6182252 --- /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 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 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/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..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 @@ -40,6 +40,15 @@ 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.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 3369a1b42..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 @@ -3,10 +3,26 @@ 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.LoggingModule; +import io.koraframework.logging.common.arg.JsonStructuredArgumentMapper; +import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; +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; 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; @@ -16,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(""" @@ -413,4 +452,382 @@ public void write(JsonGenerator gen, T value) { o.verifyNoMoreInteractions(); } + @Test + @SuppressWarnings({"unchecked"}) + 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"}) + public void testLogArgsWithMaskingMapper() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask(MaskingKeepFirst.class) + @Json + public record Credentials(@JsonField("secret") @Mask String secret, @Mask(MaskingKeepLast.class) String token, String login) {} + """, """ + @Mask + @Json + public record User(String name, Credentials credentials) {} + """, """ + public class Target { + @Log.in + public void test(@Mask @Json User arg1) {} + } + """); + compileResult.assertSuccess(); + var credentialsWriter = (JsonWriter) newObject("$Credentials_JsonWriter"); + var userWriter = (JsonWriter) newObject("$User_JsonWriter", credentialsWriter); + 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) + ); + + 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\":\"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"}) + 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"}) + public void testLogResultWithMaskingMapper() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor(), new AopAnnotationProcessor()), """ + @Mask + @Json + 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"); + } + } + """); + compileResult.assertSuccess(); + var writer = (JsonWriter) newObject("$User_JsonWriter"); + 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) + ); + + 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\":\"***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 testMaskingRulesSupportsRecursiveType() { + compile(List.of(new JsonAnnotationProcessor(), new LoggingAnnotationProcessor()), """ + @Mask + @Json + public record User(String name, @Mask String token, User manager) {} + """); + compileResult.assertSuccess(); + } + + @Test + @SuppressWarnings({"unchecked"}) + public void testMaskingRulesSupportsNestedGenericContainers() { + 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 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) + ); + + 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 testMaskingRulesComponentIsResolvedByGraph() { + 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 { + 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.MaskingRules rules + ) { + return new io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper<>(writer, rules); + } + + @Root + default Object root(@Json io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper 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); + } + + @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 21f2779bd..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 @@ -4,6 +4,15 @@ import io.koraframework.common.annotation.Root; 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.arg.JsonStructuredArgumentMapper; +import io.koraframework.logging.common.arg.MaskedStructuredArgumentMapper; +import io.koraframework.logging.common.arg.StructuredArgumentMapper; +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; @@ -22,5 +31,36 @@ default LoggingConfig loggingConfig(Config config, ConfigValueMapper StructuredArgumentMapper jsonStructuredArgumentMapper(JsonWriter writer) { + return new JsonStructuredArgumentMapper<>(writer); + } + + @DefaultComponent + 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 new file mode 100644 index 000000000..987d9b45f --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/annotation/Mask.java @@ -0,0 +1,16 @@ +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; +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 { + + Class value() default MaskingFull.class; +} 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..2558591f2 --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/arg/MaskedStructuredArgumentMapper.java @@ -0,0 +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.MaskingRules; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.io.SegmentedStringWriter; + +public class MaskedStructuredArgumentMapper implements StructuredArgumentMapper { + + private final JsonWriter writer; + private final MaskingRules rules; + private final boolean structured; + + public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules) { + this(writer, rules, true); + } + + public MaskedStructuredArgumentMapper(JsonWriter writer, MaskingRules rules, boolean structured) { + this.writer = writer; + this.rules = rules; + this.structured = structured; + } + + @Override + public void write(JsonGenerator gen, T 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/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 new file mode 100644 index 000000000..92407c77e --- /dev/null +++ b/logging/logging-common/src/main/java/io/koraframework/logging/common/masking/MaskingJsonGenerator.java @@ -0,0 +1,279 @@ +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.ArrayList; +import java.util.Arrays; +import java.util.Deque; + +public final class MaskingJsonGenerator extends JsonGeneratorDelegate { + 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, MaskingRules rules) { + super(delegate); + this.rules = rules; + } + + @Override + public JsonGenerator writeStartObject(Object forValue) throws JacksonException { + return this.writeStartObject(); + } + + @Override + public JsonGenerator writeStartObject(Object forValue, int size) throws JacksonException { + return this.writeStartObject(); + } + + @Override + public JsonGenerator writeStartObject() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth++; + 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); + } + return super.writeStartObject(); + } + + @Override + public JsonGenerator writeEndObject() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth--; + return this; + } + super.writeEndObject(); + this.popContainer(); + return this; + } + + @Override + public JsonGenerator writeStartArray() throws JacksonException { + if (this.suppressDepth > 0) { + this.suppressDepth++; + 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); + } + return super.writeStartArray(); + } + + @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(); + this.popContainer(); + return this; + } + + @Override + public JsonGenerator writeName(String name) throws JacksonException { + if (this.suppressDepth > 0) { + return this; + } + super.writeName(name); + this.pendingFieldName = 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 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; + } + + @Nullable + 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 writeMask(MaskingStrategy strategy, @Nullable Object value) throws JacksonException { + if (value == null) { + super.writeNull(); + } else { + super.writeString(strategy.mask(value)); + } + } + + private void popContainer() { + if (this.pathSegmentPushed.isEmpty()) { + return; + } + 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/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-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/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) +} 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..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 @@ -18,6 +19,8 @@ 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.doesImplement import io.koraframework.ksp.common.parseMappingData class LogKoraAspect : KoraAspect { @@ -42,8 +45,13 @@ 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 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") } @@ -115,23 +123,13 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) - 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 = 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.parseMappingData().getMapping(structuredArgumentMapper) - 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 = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } @@ -169,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.parseMappingData().getMapping(structuredArgumentMapper) - 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 = function.structuredArgumentMapperField(aspectContext, function.returnType!!.resolve().toTypeName()) writeWithMapper(mapper, OUT_PARAMETER_NAME, RESULT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -240,23 +234,13 @@ class LogKoraAspect : KoraAspect { parametersByLevel.forEach { (level, parameters) -> if (level <= inLogLevel) { parameters.forEach { parameter -> - val mapping = parameter.parseMappingData().getMapping(structuredArgumentMapper) - 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 = 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.parseMappingData().getMapping(structuredArgumentMapper) - 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 = parameter.structuredArgumentMapperField(aspectContext, parameter.type.resolve().toTypeName()) writeWithMapper(mapper, parameter.name!!.asString(), parameter.name!!.asString()) } } @@ -309,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.parseMappingData().getMapping(structuredArgumentMapper) 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), listOf()) + val mapper = function.structuredArgumentMapperField(aspectContext, flowGeneric.toTypeName()) writeWithMapper(mapper, OUT_PARAMETER_NAME, ELEMENT_FIELD_NAME) addStatement("gen.writeEndObject()") } @@ -363,6 +339,56 @@ class LogKoraAspect : KoraAspect { ?.let { Level.valueOf(it) } } + 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) + } + + 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() + } + 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? { 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..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 @@ -36,6 +36,15 @@ 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.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.* import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -98,6 +107,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..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 @@ -6,6 +6,18 @@ 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.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() { @@ -381,4 +393,262 @@ class LogAspectTest : AbstractLogAspectTest() { o.verifyNoMoreInteractions() } + @Test + fun testMaskingRulesSupportsNestedGenericContainers() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), 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 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") + 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(), MaskingRulesSymbolProcessorProvider(), 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 testLogResultWithMaskingMapper() { + compile0( + listOf(JsonSymbolProcessorProvider(), MaskingRulesSymbolProcessorProvider(), AopSymbolProcessorProvider()), + """ + @Mask + @Json + 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") + } + } + """.trimIndent() + ) + compileResult.assertSuccess() + + val writer = new("\$User_JsonWriter") as JsonWriter + 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") + 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\"}}") + } + + @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 + } + }