From 75a778296c1f990dd941e05c60ca50952faf30a6 Mon Sep 17 00:00:00 2001 From: Jorge Bornhausen Date: Wed, 18 Jun 2025 01:39:59 +0200 Subject: [PATCH 1/3] feat(deps): update quarkus.platform.version to 3.23.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1d03e52..63a16b0 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ quarkus-bom io.quarkus.platform - 3.22.3 + 3.23.3 3.14.0 -- 2.47.2 From db0026b7236a3ea4744406fdd7355c1e636273d5 Mon Sep 17 00:00:00 2001 From: Jorge Bornhausen Date: Wed, 18 Jun 2025 01:40:26 +0200 Subject: [PATCH 2/3] feat(tracing): add quarkus-tracing-service module --- pom.xml | 1 + quarkus-tracing-service/pom.xml | 44 ++++ .../tracing/LowerCaseStringConverter.java | 10 + .../commons/tracing/TracingConfiguration.java | 40 ++++ .../commons/tracing/TracingConstants.java | 18 ++ .../commons/tracing/TracingRequestFilter.java | 139 ++++++++++++ .../commons/tracing/TracingService.java | 24 +++ .../commons/tracing/TracingServiceImpl.java | 69 ++++++ .../quarkus/commons/tracing/ActorTest.java | 44 ++++ .../quarkus/commons/tracing/RawPathTest.java | 46 ++++ .../commons/tracing/RoutePatternTest.java | 204 ++++++++++++++++++ .../quarkus/commons/tracing/Test2Profile.java | 11 + .../resource/AuthenticatedResource.java | 15 ++ .../tracing/resource/BlankResource.java | 25 +++ .../LeadingAndNoTrailingResource.java | 29 +++ .../resource/LeadingAndTrailingResource.java | 29 +++ .../NoLeadingAndNoTrailingResource.java | 29 +++ .../NoLeadingAndTrailingResource.java | 29 +++ .../tracing/resource/SlashResource.java | 23 ++ .../src/test/resources/application.yaml | 38 ++++ 20 files changed, 867 insertions(+) create mode 100644 quarkus-tracing-service/pom.xml create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/LowerCaseStringConverter.java create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConfiguration.java create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConstants.java create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingRequestFilter.java create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingService.java create mode 100644 quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingServiceImpl.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/ActorTest.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RawPathTest.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RoutePatternTest.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/Test2Profile.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/AuthenticatedResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/BlankResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndNoTrailingResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndTrailingResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndNoTrailingResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndTrailingResource.java create mode 100644 quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/SlashResource.java create mode 100644 quarkus-tracing-service/src/test/resources/application.yaml diff --git a/pom.xml b/pom.xml index 63a16b0..70703dc 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ quarkus-json-service quarkus-message-digest-service quarkus-random-number-generator + quarkus-tracing-service quarkus-uuid-generator diff --git a/quarkus-tracing-service/pom.xml b/quarkus-tracing-service/pom.xml new file mode 100644 index 0000000..b95f87d --- /dev/null +++ b/quarkus-tracing-service/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + ch.phoenix.oss + quarkus-commons + 1.0.1-SNAPSHOT + + + quarkus-tracing-service + jar + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-opentelemetry + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-elytron-security-properties-file + test + + + io.quarkus + quarkus-config-yaml + test + + + io.rest-assured + rest-assured + test + + + + diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/LowerCaseStringConverter.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/LowerCaseStringConverter.java new file mode 100644 index 0000000..ede1518 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/LowerCaseStringConverter.java @@ -0,0 +1,10 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import org.eclipse.microprofile.config.spi.Converter; + +public class LowerCaseStringConverter implements Converter { + @Override + public String convert(String value) throws IllegalArgumentException, NullPointerException { + return value.toLowerCase(); + } +} diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConfiguration.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConfiguration.java new file mode 100644 index 0000000..46e2854 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConfiguration.java @@ -0,0 +1,40 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithConverter; +import io.smallrye.config.WithDefault; +import java.util.Optional; +import java.util.Set; + +@ConfigMapping(prefix = "phoenix.commons.tracing") +public interface TracingConfiguration { + + RequestFilterConfiguration requestFilter(); + + interface RequestFilterConfiguration { + + Headers headers(); + + interface Headers { + + Optional> redact(); + } + + Path path(); + + interface Path { + + @WithDefault("false") + boolean includeRaw(); + } + + Query query(); + + interface Query { + @WithDefault("false") + boolean includeRaw(); + + Optional> redact(); + } + } +} diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConstants.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConstants.java new file mode 100644 index 0000000..7022a22 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingConstants.java @@ -0,0 +1,18 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +public class TracingConstants { + + public static final String ACTOR = "actor"; + public static final String ANONYMOUS = "anonymous"; + public static final String REQUEST_ROUTE = "request.route"; + public static final String REQUEST_METHOD = "request.method"; + public static final String REQUEST_PATH_RAW = "request.path.raw"; + public static final String REQUEST_PATH_PARAMS = "request.path.params"; + public static final String REQUEST_QUERY_RAW = "request.query.raw"; + public static final String REQUEST_QUERY_PARAMS = "request.query.params"; + public static final String REQUEST_HEADERS = "request.headers"; + public static final String REQUEST_CLIENT_IP = "request.client.ip"; + public static final String SCHEDULER_JOB_NAME = "scheduler.job.name"; + + private TracingConstants() {} +} diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingRequestFilter.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingRequestFilter.java new file mode 100644 index 0000000..4a1cbd4 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingRequestFilter.java @@ -0,0 +1,139 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import io.quarkus.arc.Unremovable; +import io.quarkus.logging.Log; +import io.quarkus.security.identity.SecurityIdentity; +import io.vertx.ext.web.RoutingContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ResourceInfo; +import java.util.List; +import java.util.Set; +import org.jboss.resteasy.reactive.server.ServerRequestFilter; + +@Unremovable +public class TracingRequestFilter { + + private final RoutingContext routingContext; + private final TracingService tracingService; + private final SecurityIdentity securityIdentity; + private final TracingConfiguration configuration; + + @Inject + public TracingRequestFilter( + RoutingContext routingContext, + TracingService tracingService, + SecurityIdentity securityIdentity, + TracingConfiguration configuration) { + this.routingContext = routingContext; + this.tracingService = tracingService; + this.securityIdentity = securityIdentity; + this.configuration = configuration; + } + + @ServerRequestFilter + public void filter(ContainerRequestContext requestContext, ResourceInfo resourceInfo) { + if (securityIdentity.isAnonymous()) { + tracingService.trace(TracingConstants.ACTOR, TracingConstants.ANONYMOUS); + } else { + tracingService.trace( + TracingConstants.ACTOR, securityIdentity.getPrincipal().getName()); + } + + var method = requestContext.getMethod(); + tracingService.trace(TracingConstants.REQUEST_METHOD, method); + + var routePattern = getRoutePattern(resourceInfo); + tracingService.trace(TracingConstants.REQUEST_ROUTE, routePattern); + + var uriInfo = requestContext.getUriInfo(); + uriInfo.getPathParameters() + .forEach((key, value) -> + tracingService.trace(TracingConstants.REQUEST_PATH_PARAMS + '.' + key, joinStrings(value))); + + if (configuration.requestFilter().path().includeRaw()) { + tracingService.trace( + TracingConstants.REQUEST_PATH_RAW, uriInfo.getAbsolutePath().getRawPath()); + } + + requestContext.getHeaders().forEach((key, value) -> { + var lowerCaseKey = key.toLowerCase(); + var property = TracingConstants.REQUEST_HEADERS + '.' + lowerCaseKey; + if (configuration + .requestFilter() + .headers() + .redact() + .orElse(Set.of()) + .contains(lowerCaseKey)) { + tracingService.trace(property, "********"); + } else { + tracingService.trace(property, joinStrings(value)); + } + }); + + uriInfo.getQueryParameters() + .forEach((key, value) -> + tracingService.trace(TracingConstants.REQUEST_QUERY_PARAMS + '.' + key, joinStrings(value))); + + if (configuration.requestFilter().query().includeRaw()) { + var rawQuery = uriInfo.getRequestUri().getRawQuery(); + if (rawQuery != null && !rawQuery.isBlank()) { + tracingService.trace(TracingConstants.REQUEST_QUERY_RAW, rawQuery); + } + } + + tracingService.trace( + TracingConstants.REQUEST_CLIENT_IP, + routingContext.request().connection().remoteAddress(true).hostAddress()); + + if (Log.isTraceEnabled()) { + Log.tracef( + "Incoming request: %s %s", method, uriInfo.getAbsolutePath().getRawPath()); + } + } + + private static String joinStrings(List value) { + return String.join(", ", value); + } + + private String getRoutePattern(ResourceInfo resourceInfo) { + String classPath = getPathValue(resourceInfo.getResourceClass().getAnnotation(Path.class)); + String methodPath = getPathValue(resourceInfo.getResourceMethod().getAnnotation(Path.class)); + + if (!classPath.isEmpty()) { + if (methodPath.isEmpty()) { + return "/" + classPath; + } else { + return "/" + classPath + "/" + methodPath; + } + } else { + if (methodPath.isEmpty()) { + return "/"; + } else { + return "/" + methodPath; + } + } + } + + private static String getPathValue(Path path) { + if (path == null) { + return ""; + } + return trimSlashes(path.value()); + } + + private static String trimSlashes(String segment) { + if (segment.isEmpty() || "/".equals(segment)) { + return ""; + } + + // Assuming that it's not possible for a segment to contain //, + // thus it's possible to avoid extra ifs and whiles, as well + // as the use of regexes + int start = (segment.charAt(0) == '/') ? 1 : 0; + int end = (segment.charAt(segment.length() - 1) == '/') ? segment.length() - 1 : segment.length(); + + return segment.substring(start, end); + } +} diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingService.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingService.java new file mode 100644 index 0000000..2b1ac38 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingService.java @@ -0,0 +1,24 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +public interface TracingService { + + void clearAll(); + + void trace(String key, Object value); + + String getActor(); + + String getRequestPath(); + + String getRequestMethod(); + + String getRequestId(); + + String getTraceId(); + + String getSpanId(); + + String getClientIp(); + + String getSchedulerJob(); +} diff --git a/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingServiceImpl.java b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingServiceImpl.java new file mode 100644 index 0000000..0970b35 --- /dev/null +++ b/quarkus-tracing-service/src/main/java/ch/phoenix/oss/quarkus/commons/tracing/TracingServiceImpl.java @@ -0,0 +1,69 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import io.opentelemetry.api.trace.Span; +import io.quarkus.arc.DefaultBean; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.MDC; + +@DefaultBean +@ApplicationScoped +class TracingServiceImpl implements TracingService { + + private final Span span; + + TracingServiceImpl(Span span) { + this.span = span; + } + + @Override + public void clearAll() { + MDC.clear(); + } + + @Override + public void trace(final String key, final Object value) { + Log.infof("tracing key=%s value=%s", key, value); + MDC.put(key, value); + } + + @Override + public String getActor() { + return (String) MDC.get(TracingConstants.ACTOR); + } + + @Override + public String getRequestPath() { + return (String) MDC.get(TracingConstants.REQUEST_PATH_RAW); + } + + @Override + public String getRequestMethod() { + return (String) MDC.get(TracingConstants.REQUEST_METHOD); + } + + @Override + public String getRequestId() { + return (String) MDC.get(TracingConstants.REQUEST_HEADERS + ".x-request-id"); + } + + @Override + public String getTraceId() { + return span.getSpanContext().getTraceId(); + } + + @Override + public String getSpanId() { + return span.getSpanContext().getSpanId(); + } + + @Override + public String getClientIp() { + return (String) MDC.get(TracingConstants.REQUEST_CLIENT_IP); + } + + @Override + public String getSchedulerJob() { + return (String) MDC.get(TracingConstants.SCHEDULER_JOB_NAME); + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/ActorTest.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/ActorTest.java new file mode 100644 index 0000000..2190561 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/ActorTest.java @@ -0,0 +1,44 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class ActorTest { + + @InjectSpy + TracingService tracingService; + + @Test + void getAuthenticated() { + var route = "/authenticated"; + RestAssured.given() + .auth() + .basic("jon", "doe") + .accept(ContentType.TEXT) + .when() + .get(route) + .then() + .statusCode(200); + + verify(tracingService).trace("actor", "jon"); + verify(tracingService).trace("request.method", "GET"); + verify(tracingService).trace("request.route", route); + verify(tracingService).trace("request.headers.accept", "text/plain"); + verify(tracingService).trace("request.headers.accept-encoding", "gzip,deflate"); + verify(tracingService).trace("request.headers.authorization", "Basic am9uOmRvZQ=="); + verify(tracingService).trace("request.headers.connection", "Keep-Alive"); + verify(tracingService).trace(eq("request.headers.host"), startsWith("localhost:")); + verify(tracingService).trace(eq("request.headers.user-agent"), startsWith("Apache-HttpClient")); + verify(tracingService).trace("request.client.ip", "127.0.0.1"); + verifyNoMoreInteractions(tracingService); + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RawPathTest.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RawPathTest.java new file mode 100644 index 0000000..96d8956 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RawPathTest.java @@ -0,0 +1,46 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.mockito.InjectSpy; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(Test2Profile.class) +public class RawPathTest { + + @InjectSpy + TracingService tracingService; + + @Test + void getWithRawPath() { + var route = "/no-leading-and-trailing/{param}/{param2}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verify(tracingService).trace("actor", "anonymous"); + verify(tracingService).trace("request.method", "GET"); + verify(tracingService).trace("request.route", route); + verify(tracingService).trace("request.path.params.param", "1"); + verify(tracingService).trace("request.path.params.param2", "2"); + verify(tracingService).trace("request.path.raw", "/no-leading-and-trailing/1/2"); + verify(tracingService).trace("request.headers.accept", "text/plain"); + verify(tracingService).trace("request.headers.accept-encoding", "gzip,deflate"); + verify(tracingService).trace("request.headers.connection", "Keep-Alive"); + verify(tracingService).trace(eq("request.headers.host"), startsWith("localhost:")); + verify(tracingService).trace(eq("request.headers.user-agent"), startsWith("Apache-HttpClient")); + verify(tracingService).trace("request.client.ip", "127.0.0.1"); + verifyNoMoreInteractions(tracingService); + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RoutePatternTest.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RoutePatternTest.java new file mode 100644 index 0000000..11f1689 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/RoutePatternTest.java @@ -0,0 +1,204 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.Map; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class RoutePatternTest { + + @InjectSpy + TracingService tracingService; + + @Test + void getBlankResource() { + var route = "/"; + RestAssured.given().accept(ContentType.TEXT).when().get(route).then().statusCode(200); + + verifyGetTracing(route, Map.of()); + } + + @Test + void postBlankResource() { + var route = "/"; + RestAssured.given().accept(ContentType.TEXT).when().post(route).then().statusCode(200); + + verify(tracingService).trace("actor", "anonymous"); + verify(tracingService).trace("request.method", "POST"); + verify(tracingService).trace("request.route", route); + verify(tracingService).trace("request.headers.accept", "text/plain"); + verify(tracingService).trace("request.headers.accept-encoding", "gzip,deflate"); + verify(tracingService).trace("request.headers.connection", "Keep-Alive"); + verify(tracingService).trace("request.headers.content-length", "0"); + verify(tracingService) + .trace("request.headers.content-type", "application/x-www-form-urlencoded; charset=ISO-8859-1"); + verify(tracingService).trace(eq("request.headers.host"), startsWith("localhost:")); + verify(tracingService).trace(eq("request.headers.user-agent"), startsWith("Apache-HttpClient")); + verify(tracingService).trace("request.client.ip", "127.0.0.1"); + verifyNoMoreInteractions(tracingService); + } + + @Test + void getLeadingResource() { + var route = "/leading/{id}/{anotherId}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("id", "1", "anotherId", "2")); + } + + @Test + void getTrailingResource() { + var route = "/{id}/{anotherId}/trailing"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("id", "1", "anotherId", "2")); + } + + @Test + void getLeadingAndNoTrailingResource() { + var route = "/leading-and-no-trailing"; + RestAssured.given().accept(ContentType.TEXT).when().get(route).then().statusCode(200); + + verifyGetTracing(route, Map.of()); + } + + @Test + void getLeadingAndNoTrailingWithSingleParamResource() { + var route = "/leading-and-no-trailing/{param}"; + RestAssured.given().accept(ContentType.TEXT).when().get(route, 1).then().statusCode(200); + + verifyGetTracing(route, Map.of("param", "1")); + } + + @Test + void getLeadingAndNoTrailingWithMultiParamResource() { + var route = "/leading-and-no-trailing/{param}/{param2}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("param", "1", "param2", "2")); + } + + @Test + void getLeadingAndTrailingResource() { + var route = "/leading-and-trailing"; + RestAssured.given().accept(ContentType.TEXT).when().get(route).then().statusCode(200); + + verifyGetTracing(route, Map.of()); + } + + @Test + void getLeadingAndTrailingWithSingleParamResource() { + var route = "/leading-and-trailing/{param}"; + RestAssured.given().accept(ContentType.TEXT).when().get(route, 1).then().statusCode(200); + + verifyGetTracing(route, Map.of("param", "1")); + } + + @Test + void getLeadingAndTrailingWithMultiParamResource() { + var route = "/leading-and-trailing/{param}/{param2}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("param", "1", "param2", "2")); + } + + @Test + void getNoLeadingAndNoTrailingResource() { + var route = "/no-leading-and-no-trailing"; + RestAssured.given().accept(ContentType.TEXT).when().get(route).then().statusCode(200); + + verifyGetTracing(route, Map.of()); + } + + @Test + void geNoLeadingAndNoTrailingWithSingleParamResource() { + var route = "/no-leading-and-no-trailing/{param}"; + RestAssured.given().accept(ContentType.TEXT).when().get(route, 1).then().statusCode(200); + + verifyGetTracing(route, Map.of("param", "1")); + } + + @Test + void getNoLeadingAndNoTrailingWithMultiParamResource() { + var route = "/no-leading-and-no-trailing/{param}/{param2}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("param", "1", "param2", "2")); + } + + @Test + void getNoLeadingAndTrailingResource() { + var route = "/no-leading-and-trailing"; + RestAssured.given().accept(ContentType.TEXT).when().get(route).then().statusCode(200); + + verifyGetTracing(route, Map.of()); + } + + @Test + void getNoLeadingAndTrailingWithSingleParamResource() { + var route = "/no-leading-and-trailing/{param}"; + RestAssured.given().accept(ContentType.TEXT).when().get(route, 1).then().statusCode(200); + + verifyGetTracing(route, Map.of("param", "1")); + } + + @Test + void getNoLeadingAndTrailingWithMultiParamResource() { + var route = "/no-leading-and-trailing/{param}/{param2}"; + RestAssured.given() + .accept(ContentType.TEXT) + .when() + .get(route, 1, 2) + .then() + .statusCode(200); + + verifyGetTracing(route, Map.of("param", "1", "param2", "2")); + } + + private void verifyGetTracing(String route, Map pathParams) { + verify(tracingService).trace("actor", "anonymous"); + verify(tracingService).trace("request.method", "GET"); + verify(tracingService).trace("request.route", route); + pathParams.forEach((key, value) -> verify(tracingService).trace("request.path.params." + key, value)); + verify(tracingService).trace("request.headers.accept", "text/plain"); + verify(tracingService).trace("request.headers.accept-encoding", "gzip,deflate"); + verify(tracingService).trace("request.headers.connection", "Keep-Alive"); + verify(tracingService).trace(eq("request.headers.host"), startsWith("localhost:")); + verify(tracingService).trace(eq("request.headers.user-agent"), startsWith("Apache-HttpClient")); + verify(tracingService).trace("request.client.ip", "127.0.0.1"); + verifyNoMoreInteractions(tracingService); + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/Test2Profile.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/Test2Profile.java new file mode 100644 index 0000000..f98dcff --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/Test2Profile.java @@ -0,0 +1,11 @@ +package ch.phoenix.oss.quarkus.commons.tracing; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class Test2Profile implements QuarkusTestProfile { + + @Override + public String getConfigProfile() { + return "test2"; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/AuthenticatedResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/AuthenticatedResource.java new file mode 100644 index 0000000..d18b967 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/AuthenticatedResource.java @@ -0,0 +1,15 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import io.quarkus.security.Authenticated; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Authenticated +@Path("authenticated") +public class AuthenticatedResource { + + @GET + public String get() { + return "Hello user"; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/BlankResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/BlankResource.java new file mode 100644 index 0000000..04c5df9 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/BlankResource.java @@ -0,0 +1,25 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.annotation.security.PermitAll; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("") +@Produces(MediaType.TEXT_PLAIN) +public class BlankResource { + + @GET + @PermitAll + public String get() { + return "get"; + } + + @POST + @Path("") + public String post() { + return "post"; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndNoTrailingResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndNoTrailingResource.java new file mode 100644 index 0000000..b03a7ae --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndNoTrailingResource.java @@ -0,0 +1,29 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/leading-and-no-trailing") +@Produces(MediaType.TEXT_PLAIN) +public class LeadingAndNoTrailingResource { + + @GET + @Path("") + public String root() { + return "root"; + } + + @GET + @Path("/{param}") + public String singleParam(String param) { + return param; + } + + @GET + @Path("/{param}/{param2}") + public String multiParam(String param, String param2) { + return param + "/" + param2; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndTrailingResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndTrailingResource.java new file mode 100644 index 0000000..1dbc175 --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/LeadingAndTrailingResource.java @@ -0,0 +1,29 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/leading-and-trailing/") +@Produces(MediaType.TEXT_PLAIN) +public class LeadingAndTrailingResource { + + @GET + @Path("/") + public String root() { + return "root"; + } + + @GET + @Path("/{param}/") + public String singleParam(String param) { + return param; + } + + @GET + @Path("/{param}/{param2}/") + public String multiParam(String param, String param2) { + return param + "/" + param2; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndNoTrailingResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndNoTrailingResource.java new file mode 100644 index 0000000..d75dade --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndNoTrailingResource.java @@ -0,0 +1,29 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("no-leading-and-no-trailing") +@Produces(MediaType.TEXT_PLAIN) +public class NoLeadingAndNoTrailingResource { + + @GET + @Path("") + public String root() { + return "root"; + } + + @GET + @Path("{param}") + public String singleParam(String param) { + return param; + } + + @GET + @Path("{param}/{param2}") + public String multiParam(String param, String param2) { + return param + "/" + param2; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndTrailingResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndTrailingResource.java new file mode 100644 index 0000000..9c9805a --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/NoLeadingAndTrailingResource.java @@ -0,0 +1,29 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("no-leading-and-trailing/") +@Produces(MediaType.TEXT_PLAIN) +public class NoLeadingAndTrailingResource { + + @GET + @Path("/") + public String root() { + return "root"; + } + + @GET + @Path("{param}/") + public String singleParam(String param) { + return param; + } + + @GET + @Path("{param}/{param2}/") + public String multiParam(String param, String param2) { + return param + "/" + param2; + } +} diff --git a/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/SlashResource.java b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/SlashResource.java new file mode 100644 index 0000000..690c84a --- /dev/null +++ b/quarkus-tracing-service/src/test/java/ch/phoenix/oss/quarkus/commons/tracing/resource/SlashResource.java @@ -0,0 +1,23 @@ +package ch.phoenix.oss.quarkus.commons.tracing.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/") +@Produces(MediaType.TEXT_PLAIN) +public class SlashResource { + + @GET + @Path("/leading/{id}/{anotherId}") + public String doubleLeading(int id, int anotherId) { + return "leading/" + id + "/" + anotherId; + } + + @GET + @Path("{id}/{anotherId}/trailing/") + public String doubleTrailing(int id, int anotherId) { + return id + "/" + anotherId + "/trailing"; + } +} diff --git a/quarkus-tracing-service/src/test/resources/application.yaml b/quarkus-tracing-service/src/test/resources/application.yaml new file mode 100644 index 0000000..9d0943f --- /dev/null +++ b/quarkus-tracing-service/src/test/resources/application.yaml @@ -0,0 +1,38 @@ +quarkus: + http: + test-port: 0 + log: + category: + "org.apache.http.wire": + level: DEBUG # set to DEBUG when RestAssured logs are necessary to understand test failures + otel: + traces: + exporter: none + security: + users: + embedded: + enabled: true + plain-text: true + users: + jon: doe + +"%test2": + quarkus: + log: + min-level: TRACE + category: + "ch.phoenix.oss.quarkus.commons.tracing": + level: TRACE + phoenix: + commons: + tracing: + request-filter: + path: + include-raw: true + headers: + redact: + - AUTHORIZATION + query: + include-raw: true + redact: + - ACCESS_TOKEN \ No newline at end of file -- 2.47.2 From f268c4a27a8c2c55b2668059f62b4a67a606525c Mon Sep 17 00:00:00 2001 From: Jorge Bornhausen Date: Wed, 18 Jun 2025 01:41:16 +0200 Subject: [PATCH 3/3] feat(audit): add quarkus-audit-tools module --- pom.xml | 1 + quarkus-audit-tools/pom.xml | 88 ++++++++++++ .../commons/audit/AuditRevisionListener.java | 20 +++ .../commons/audit/AuditedPanacheEntity.java | 30 ++++ .../audit/AuditedPanacheEntityBase.java | 30 ++++ .../audit/DefaultRevisionContextProvider.java | 67 +++++++++ .../oss/quarkus/commons/audit/Revision.java | 58 ++++++++ .../audit/RevisionContextProvider.java | 16 +++ .../audit/AuditedPanacheEntityBaseTest.java | 136 ++++++++++++++++++ .../audit/AuditedPanacheEntityTest.java | 136 ++++++++++++++++++ .../DefaultRevisionContextProviderTest.java | 32 +++++ .../quarkus/commons/audit/RevisionTest.java | 28 ++++ .../oss/quarkus/commons/audit/TestEntity.java | 23 +++ .../quarkus/commons/audit/TestEntity2.java | 29 ++++ .../src/test/resources/application.yaml | 22 +++ .../db/migration/V1__create_schema.sql | 46 ++++++ .../db/migration/V2__create_audit_trigger.sql | 35 +++++ .../src/test/resources/import.sql | 37 +++++ 18 files changed, 834 insertions(+) create mode 100644 quarkus-audit-tools/pom.xml create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditRevisionListener.java create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntity.java create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBase.java create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProvider.java create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/Revision.java create mode 100644 quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/RevisionContextProvider.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBaseTest.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityTest.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProviderTest.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/RevisionTest.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity.java create mode 100644 quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity2.java create mode 100644 quarkus-audit-tools/src/test/resources/application.yaml create mode 100644 quarkus-audit-tools/src/test/resources/db/migration/V1__create_schema.sql create mode 100644 quarkus-audit-tools/src/test/resources/db/migration/V2__create_audit_trigger.sql create mode 100644 quarkus-audit-tools/src/test/resources/import.sql diff --git a/pom.xml b/pom.xml index 70703dc..2a94134 100644 --- a/pom.xml +++ b/pom.xml @@ -7,6 +7,7 @@ pom + quarkus-audit-tools quarkus-clock-service quarkus-json-service quarkus-message-digest-service diff --git a/quarkus-audit-tools/pom.xml b/quarkus-audit-tools/pom.xml new file mode 100644 index 0000000..be3a1e2 --- /dev/null +++ b/quarkus-audit-tools/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + ch.phoenix.oss + quarkus-commons + 1.0.1-SNAPSHOT + + + quarkus-audit-tools + jar + + + + ch.phoenix.oss + quarkus-tracing-service + ${project.version} + + + io.quarkus + quarkus-hibernate-envers + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-flyway + test + + + io.quarkus + quarkus-flyway-postgresql + test + + + io.quarkus + quarkus-elytron-security-properties-file + test + + + io.quarkus + quarkus-config-yaml + test + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditRevisionListener.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditRevisionListener.java new file mode 100644 index 0000000..29884f8 --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditRevisionListener.java @@ -0,0 +1,20 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import jakarta.enterprise.inject.spi.CDI; +import org.hibernate.envers.RevisionListener; + +public class AuditRevisionListener implements RevisionListener { + + @Override + public void newRevision(Object revisionEntity) { + var provider = CDI.current().select(RevisionContextProvider.class).get(); + + var rev = (Revision) revisionEntity; + rev.actor = provider.getActor(); + rev.traceId = provider.getTraceId(); + rev.spanId = provider.getSpanId(); + rev.requestId = provider.getRequestId(); + rev.clientIp = provider.getClientIp(); + rev.hostName = provider.getHostName(); + } +} diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntity.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntity.java new file mode 100644 index 0000000..4540cbf --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntity.java @@ -0,0 +1,30 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import org.hibernate.envers.NotAudited; + +/** + * The goal of this class is to have on the entity itself the exact same + * timestamps as the ones from revisions generated by envers. Because of that, + * we can't use @CreationTimestamp and @UpdateTimestamp, as those timestamp values + * are managed by different Hibernate classes, so the generated values will drift. + *

+ * Manually setting these values to match envers revisions would be error-prone, + * verbose and tedious. So, the recommendation is to implement triggers on the + * audit tables which will update the main entity whenever a revision is created. + * An example of how to do that can be found in this module's integration tests. + */ +@MappedSuperclass +public abstract class AuditedPanacheEntity extends PanacheEntity { + + @NotAudited + @Column(updatable = false) + public Instant createdAt; + + @NotAudited + @Column(updatable = false) + public Instant lastUpdatedAt; +} diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBase.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBase.java new file mode 100644 index 0000000..a6114b3 --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBase.java @@ -0,0 +1,30 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import org.hibernate.envers.NotAudited; + +/** + * The goal of this class is to have on the entity itself the exact same + * timestamps as the ones from revisions generated by envers. Because of that, + * we can't use @CreationTimestamp and @UpdateTimestamp, as those timestamp values + * are managed by different Hibernate classes, so the generated values will drift. + *

+ * Manually setting these values to match envers revisions would be error-prone, + * verbose and tedious. So, the recommendation is to implement triggers on the + * audit tables which will update the main entity whenever a revision is created. + * An example of how to do that can be found in this module's integration tests. + */ +@MappedSuperclass +public abstract class AuditedPanacheEntityBase extends PanacheEntityBase { + + @NotAudited + @Column(updatable = false) + public Instant createdAt; + + @NotAudited + @Column(updatable = false) + public Instant lastUpdatedAt; +} diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProvider.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProvider.java new file mode 100644 index 0000000..18ebba6 --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProvider.java @@ -0,0 +1,67 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import ch.phoenix.oss.quarkus.commons.tracing.TracingService; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import io.quarkus.arc.DefaultBean; +import io.quarkus.arc.Unremovable; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.net.InetAddress; +import java.net.UnknownHostException; + +@Unremovable +@DefaultBean +@ApplicationScoped +class DefaultRevisionContextProvider implements RevisionContextProvider { + + private static final String UNKNOWN = "unknown"; + + private final TracingService tracingService; + + @Inject + DefaultRevisionContextProvider(TracingService tracingService) { + this.tracingService = tracingService; + } + + @Override + @WithSpan + public String getActor() { + return tracingService.getActor(); + } + + @Override + @WithSpan + public String getTraceId() { + return tracingService.getTraceId(); + } + + @Override + @WithSpan + public String getSpanId() { + return tracingService.getSpanId(); + } + + @Override + @WithSpan + public String getRequestId() { + return tracingService.getRequestId(); + } + + @Override + @WithSpan + public String getClientIp() { + return tracingService.getClientIp(); + } + + @Override + @WithSpan + public String getHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + Log.error("Unable to determine host name", e); + return UNKNOWN; + } + } +} diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/Revision.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/Revision.java new file mode 100644 index 0000000..fa3085a --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/Revision.java @@ -0,0 +1,58 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.Objects; +import org.hibernate.envers.RevisionEntity; +import org.hibernate.envers.RevisionNumber; +import org.hibernate.envers.RevisionTimestamp; + +@Entity +@Table(name = "revinfo") +@RevisionEntity(AuditRevisionListener.class) +public class Revision extends PanacheEntityBase { + + @Id + @GeneratedValue + @RevisionNumber + public long rev; + + @RevisionTimestamp + @Column(nullable = false, updatable = false) + public Instant timestamp; + + @Column(updatable = false) + public String actor; + + @Column(updatable = false) + public String traceId; + + @Column(updatable = false) + public String spanId; + + @Column(updatable = false) + public String requestId; + + @Column(updatable = false) + public String clientIp; + + @Column(updatable = false) + public String hostName; + + @Override + public boolean equals(Object o) { + if (!(o instanceof Revision that)) return false; + return rev == that.rev; + } + + @Override + public int hashCode() { + return Objects.hashCode(rev); + } + + @Override + public String toString() { + return "Revision{rev=" + rev + '}'; + } +} diff --git a/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/RevisionContextProvider.java b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/RevisionContextProvider.java new file mode 100644 index 0000000..477c44b --- /dev/null +++ b/quarkus-audit-tools/src/main/java/ch/phoenix/oss/quarkus/commons/audit/RevisionContextProvider.java @@ -0,0 +1,16 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +public interface RevisionContextProvider { + + String getActor(); + + String getTraceId(); + + String getSpanId(); + + String getRequestId(); + + String getClientIp(); + + String getHostName(); +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBaseTest.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBaseTest.java new file mode 100644 index 0000000..f4ba0de --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityBaseTest.java @@ -0,0 +1,136 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +import ch.phoenix.oss.quarkus.commons.tracing.TracingService; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import org.hibernate.envers.AuditReaderFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class AuditedPanacheEntityBaseTest { + + @Inject + EntityManager entityManager; + + @InjectSpy + TracingService tracingService; + + @SuppressWarnings("Convert2MethodRef") + @BeforeEach + void setup() { + QuarkusTransaction.requiringNew().run(() -> TestEntity2.deleteAll()); + } + + @Test + void persistAndUpdate() { + when(tracingService.getRequestId()).thenReturn("00000000-0000-0000-0000-000000000000"); + when(tracingService.getSpanId()).thenReturn("0000000000000000"); + when(tracingService.getTraceId()).thenReturn("00000000000000000000000000000000"); + when(tracingService.getActor()).thenReturn("unknown"); + when(tracingService.getClientIp()).thenReturn("unknown"); + + var now = Instant.now(); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = new TestEntity2("something"); + entity.persist(); + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity2.findBySomething("something"); + assertAll( + () -> assertThat(entity.createdAt) + .as("createdAt should be after or equal to expected value") + .isAfterOrEqualTo(now), + () -> assertThat(entity.lastUpdatedAt) + .as("lastUpdatedAt should be equal to createdAt") + .isEqualTo(entity.createdAt)); + + var auditReader = AuditReaderFactory.get(entityManager); + + var revisions = auditReader.getRevisions(TestEntity2.class, entity.id); + assertThat(revisions).hasSize(1); + + var revInfo = entityManager.find(Revision.class, revisions.getFirst()); + assertThat(revInfo).isNotNull(); + + assertAll( + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should be equal to entity's createdAt timestamp") + .isEqualTo(entity.createdAt), + () -> assertThat(revInfo.actor) + .as("actor should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.traceId) + .as("traceId should match expected value") + .isEqualTo("00000000000000000000000000000000"), + () -> assertThat(revInfo.spanId) + .as("spanId should match expected value") + .isEqualTo("0000000000000000"), + () -> assertThat(revInfo.requestId) + .as("requestId should match expected value") + .isEqualTo("00000000-0000-0000-0000-000000000000"), + () -> assertThat(revInfo.clientIp) + .as("clientIp should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.hostName) + .as("hostName should not be blank") + .isNotBlank()); + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity2.findBySomething("something"); + entity.something = "else"; + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity2.findBySomething("else"); + assertAll(() -> assertThat(entity.createdAt) + .as("createdAt should be before lastUpdatedAt") + .isBefore(entity.lastUpdatedAt)); + + var auditReader = AuditReaderFactory.get(entityManager); + + var revisions = auditReader.getRevisions(TestEntity2.class, entity.id); + assertThat(revisions).hasSize(2); + + Revision revInfo = Revision.findById(revisions.getLast()); + assertThat(revInfo).isNotNull(); + + assertAll( + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should not be equal to entity's createdAt") + .isNotEqualTo(entity.createdAt), + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should be equal to entity's lastUpdatedAt") + .isEqualTo(entity.lastUpdatedAt), + () -> assertThat(revInfo.actor) + .as("actor should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.traceId) + .as("traceId should match expected value") + .isEqualTo("00000000000000000000000000000000"), + () -> assertThat(revInfo.spanId) + .as("spanId should match expected value") + .isEqualTo("0000000000000000"), + () -> assertThat(revInfo.requestId) + .as("requestId should match expected value") + .isEqualTo("00000000-0000-0000-0000-000000000000"), + () -> assertThat(revInfo.clientIp) + .as("clientIp should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.hostName) + .as("hostName should not be blank") + .isNotBlank()); + }); + } +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityTest.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityTest.java new file mode 100644 index 0000000..0f182ee --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/AuditedPanacheEntityTest.java @@ -0,0 +1,136 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +import ch.phoenix.oss.quarkus.commons.tracing.TracingService; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import org.hibernate.envers.AuditReaderFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class AuditedPanacheEntityTest { + + @Inject + EntityManager entityManager; + + @InjectSpy + TracingService tracingService; + + @SuppressWarnings("Convert2MethodRef") + @BeforeEach + void setup() { + QuarkusTransaction.requiringNew().run(() -> TestEntity.deleteAll()); + } + + @Test + void persistAndUpdate() { + when(tracingService.getRequestId()).thenReturn("00000000-0000-0000-0000-000000000000"); + when(tracingService.getSpanId()).thenReturn("0000000000000000"); + when(tracingService.getTraceId()).thenReturn("00000000000000000000000000000000"); + when(tracingService.getActor()).thenReturn("unknown"); + when(tracingService.getClientIp()).thenReturn("unknown"); + + var now = Instant.now(); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = new TestEntity("something"); + entity.persist(); + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity.findBySomething("something"); + assertAll( + () -> assertThat(entity.createdAt) + .as("createdAt should be after or equal to expected value") + .isAfterOrEqualTo(now), + () -> assertThat(entity.lastUpdatedAt) + .as("lastUpdatedAt should be equal to createdAt") + .isEqualTo(entity.createdAt)); + + var auditReader = AuditReaderFactory.get(entityManager); + + var revisions = auditReader.getRevisions(TestEntity.class, entity.id); + assertThat(revisions).hasSize(1); + + var revInfo = entityManager.find(Revision.class, revisions.getFirst()); + assertThat(revInfo).isNotNull(); + + assertAll( + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should be equal to entity's createdAt timestamp") + .isEqualTo(entity.createdAt), + () -> assertThat(revInfo.actor) + .as("actor should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.traceId) + .as("traceId should match expected value") + .isEqualTo("00000000000000000000000000000000"), + () -> assertThat(revInfo.spanId) + .as("spanId should match expected value") + .isEqualTo("0000000000000000"), + () -> assertThat(revInfo.requestId) + .as("requestId should match expected value") + .isEqualTo("00000000-0000-0000-0000-000000000000"), + () -> assertThat(revInfo.clientIp) + .as("clientIp should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.hostName) + .as("hostName should not be blank") + .isNotBlank()); + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity.findBySomething("something"); + entity.something = "else"; + }); + + QuarkusTransaction.requiringNew().run(() -> { + var entity = TestEntity.findBySomething("else"); + assertAll(() -> assertThat(entity.createdAt) + .as("createdAt should be before lastUpdatedAt") + .isBefore(entity.lastUpdatedAt)); + + var auditReader = AuditReaderFactory.get(entityManager); + + var revisions = auditReader.getRevisions(TestEntity.class, entity.id); + assertThat(revisions).hasSize(2); + + Revision revInfo = Revision.findById(revisions.getLast()); + assertThat(revInfo).isNotNull(); + + assertAll( + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should not be equal to entity's createdAt") + .isNotEqualTo(entity.createdAt), + () -> assertThat(revInfo.timestamp) + .as("revision timestamp should be equal to entity's lastUpdatedAt") + .isEqualTo(entity.lastUpdatedAt), + () -> assertThat(revInfo.actor) + .as("actor should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.traceId) + .as("traceId should match expected value") + .isEqualTo("00000000000000000000000000000000"), + () -> assertThat(revInfo.spanId) + .as("spanId should match expected value") + .isEqualTo("0000000000000000"), + () -> assertThat(revInfo.requestId) + .as("requestId should match expected value") + .isEqualTo("00000000-0000-0000-0000-000000000000"), + () -> assertThat(revInfo.clientIp) + .as("clientIp should match expected value") + .isEqualTo("unknown"), + () -> assertThat(revInfo.hostName) + .as("hostName should not be blank") + .isNotBlank()); + }); + } +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProviderTest.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProviderTest.java new file mode 100644 index 0000000..5ad9ff7 --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/DefaultRevisionContextProviderTest.java @@ -0,0 +1,32 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class DefaultRevisionContextProviderTest { + + @Inject + DefaultRevisionContextProvider underTest; + + @Test + void getHostName() { + assertThat(underTest.getHostName()).isNotBlank(); + } + + @Test + void getHostNameWhenUnknown() { + try (var inetMock = mockStatic(InetAddress.class)) { + inetMock.when(InetAddress::getLocalHost).thenThrow(new UnknownHostException("simulated failure")); + + assertThat(underTest.getHostName()).isEqualTo("unknown"); + } + } +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/RevisionTest.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/RevisionTest.java new file mode 100644 index 0000000..1b08c56 --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/RevisionTest.java @@ -0,0 +1,28 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class RevisionTest { + + @Test + void testEquals() { + var r1 = new Revision(); + r1.rev = 1; + + var r2 = new Revision(); + r2.rev = 1; + + assertThat(r1).as("Revisions should be equal").isEqualTo(r2); + } + + @Test + void testHashCode() {} + + @Test + void testToString() {} +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity.java new file mode 100644 index 0000000..2dea9ea --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity.java @@ -0,0 +1,23 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + +@Entity +@Audited +@Table(name = "test_entity") +public class TestEntity extends AuditedPanacheEntity { + + public String something; + + public TestEntity() {} + + public TestEntity(String something) { + this.something = something; + } + + public static TestEntity findBySomething(String something) { + return find("something", something).singleResult(); + } +} diff --git a/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity2.java b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity2.java new file mode 100644 index 0000000..922b96a --- /dev/null +++ b/quarkus-audit-tools/src/test/java/ch/phoenix/oss/quarkus/commons/audit/TestEntity2.java @@ -0,0 +1,29 @@ +package ch.phoenix.oss.quarkus.commons.audit; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + +@Entity +@Audited +@Table(name = "test_entity") +public class TestEntity2 extends AuditedPanacheEntityBase { + + @Id + @GeneratedValue + public Long id; + + public String something; + + public TestEntity2() {} + + public TestEntity2(String something) { + this.something = something; + } + + public static TestEntity2 findBySomething(String something) { + return find("something", something).singleResult(); + } +} diff --git a/quarkus-audit-tools/src/test/resources/application.yaml b/quarkus-audit-tools/src/test/resources/application.yaml new file mode 100644 index 0000000..d44d31a --- /dev/null +++ b/quarkus-audit-tools/src/test/resources/application.yaml @@ -0,0 +1,22 @@ +quarkus: + flyway: + migrate-at-start: true + datasource: + db-kind: postgresql + hibernate-orm: + sql-load-script: no-file + schema-management: + strategy: none + log: + sql: true + bind-parameters: true + hibernate-envers: + audit-strategy: org.hibernate.envers.strategy.internal.ValidityAuditStrategy + revision-listener: ch.phoenix.oss.quarkus.commons.audit.AuditRevisionListener + security: + users: + embedded: + enabled: true + plain-text: true + users: + jon: doe diff --git a/quarkus-audit-tools/src/test/resources/db/migration/V1__create_schema.sql b/quarkus-audit-tools/src/test/resources/db/migration/V1__create_schema.sql new file mode 100644 index 0000000..daabe85 --- /dev/null +++ b/quarkus-audit-tools/src/test/resources/db/migration/V1__create_schema.sql @@ -0,0 +1,46 @@ +create sequence revinfo_seq start with 1 increment by 50; +create sequence test_entity_seq start with 1 increment by 50; + +create table revinfo +( + rev bigint not null, + timestamp timestamp(6) with time zone not null, + actor varchar(255), + spanId varchar(255), + traceId varchar(255), + requestId varchar(255), + clientIp varchar(255), + hostName varchar(255), + primary key (rev) +); + +create table test_entity +( + + id bigint primary key not null, + something varchar(255), + createdAt timestamp(6) with time zone, + lastUpdatedAt timestamp(6) with time zone +); + +create table test_entity_aud +( + revtype smallint, + rev bigint not null, + revend bigint, + id bigint not null, + something varchar(255), + primary key (rev, id) +); + +alter table if exists test_entity_aud + add constraint fk_rev__revinfo_rev + foreign key (rev) + references revinfo; + + +alter table if exists test_entity_aud + add constraint fk_revend__revinfo_rev + foreign key (revend) + references revinfo; + diff --git a/quarkus-audit-tools/src/test/resources/db/migration/V2__create_audit_trigger.sql b/quarkus-audit-tools/src/test/resources/db/migration/V2__create_audit_trigger.sql new file mode 100644 index 0000000..f76a2cb --- /dev/null +++ b/quarkus-audit-tools/src/test/resources/db/migration/V2__create_audit_trigger.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE FUNCTION trg_test_entity_aud_apply_rev() + RETURNS TRIGGER + LANGUAGE plpgsql +AS $func$ +DECLARE + ts TIMESTAMP; +BEGIN + -- fetch the exact revision timestamp from revinfo + SELECT r.timestamp + INTO ts + FROM revinfo r + WHERE r.rev = NEW.rev; + + -- only set created_at once, when still NULL + UPDATE test_entity + SET createdAt = ts + WHERE id = NEW.id + AND createdAt IS NULL; + + -- always bump last_updated_at + UPDATE test_entity + SET lastUpdatedAt = ts + WHERE id = NEW.id; + + RETURN NULL; +END; +$func$; + +DROP TRIGGER IF EXISTS trg_test_entity_aud_after_insert + ON test_entity_aud; + +CREATE TRIGGER trg_test_entity_aud_after_insert + AFTER INSERT ON test_entity_aud + FOR EACH ROW +EXECUTE FUNCTION trg_test_entity_aud_apply_rev(); diff --git a/quarkus-audit-tools/src/test/resources/import.sql b/quarkus-audit-tools/src/test/resources/import.sql new file mode 100644 index 0000000..c4a0b85 --- /dev/null +++ b/quarkus-audit-tools/src/test/resources/import.sql @@ -0,0 +1,37 @@ +-- 1) Create or replace the trigger function +CREATE OR REPLACE FUNCTION trg_test_entity_aud_apply_rev() + RETURNS TRIGGER + LANGUAGE plpgsql +AS $func$ +DECLARE + ts TIMESTAMP; +BEGIN + -- fetch the exact revision timestamp from revinfo + SELECT r.timestamp + INTO ts + FROM revinfo r + WHERE r.rev = NEW.rev; + + -- only set created_at once, when still NULL + UPDATE test_entity + SET created_at = ts + WHERE id = NEW.id + AND created_at IS NULL; + + -- always bump last_updated_at + UPDATE test_entity + SET last_updated_at = ts + WHERE id = NEW.id; + + RETURN NULL; -- AFTER trigger ignores return value +END; +$func$; + +-- 2) Drop any existing trigger, then attach the new one +DROP TRIGGER IF EXISTS trg_test_entity_aud_after_insert + ON test_entity_aud; + +CREATE TRIGGER trg_test_entity_aud_after_insert + AFTER INSERT ON test_entity_aud + FOR EACH ROW +EXECUTE FUNCTION trg_test_entity_aud_apply_rev(); -- 2.47.2