diff --git a/docs/src/main/asciidoc/opentelemetry.adoc b/docs/src/main/asciidoc/opentelemetry.adoc index 5462114043b5b6..dc2904f660eb47 100644 --- a/docs/src/main/asciidoc/opentelemetry.adoc +++ b/docs/src/main/asciidoc/opentelemetry.adoc @@ -398,6 +398,23 @@ public class CustomConfiguration { } ---- +==== End User attributes + +When enabled, Quarkus adds OpenTelemetry End User attributes as Span attributes. +The attributes are only added when authentication has already happened on a best-efforts basis. + +[source,application.properties] +---- +quarkus.otel.traces.eusp.enabled=true <1> +quarkus.http.auth.proactive=true <2> +---- +<1> Enable the End User Attributes feature so that the `SecurityIdentity` principal and roles are added as Span attributes. +The End User attributes are personally identifiable information, therefore make sure you want to export them before you enable this feature. +<2> Optionally enable proactive authentication. +The best possible results are achieved when proactive authentication is enabled because the authentication happens sooner. + +IMPORTANT: This feature is not supported when a custom xref:security-customization.adoc#jaxrs-security-context[Jakarta REST SecurityContexts] is used. + [[sampler]] === Sampler A https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#sampling[sampler] decides whether a trace should be discarded or forwarded, effectively managing noise and reducing overhead by limiting the number of collected traces sent to the collector. diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java index 14f09b0a37753f..108498e22640aa 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java @@ -1,7 +1,12 @@ package io.quarkus.opentelemetry.deployment.tracing; import static io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig.SecurityEvents.SecurityEventType.ALL; +import static io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig.SecurityEvents.SecurityEventType.AUTHENTICATION_SUCCESS; +import static io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig.SecurityEvents.SecurityEventType.AUTHORIZATION_FAILURE; +import static io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig.SecurityEvents.SecurityEventType.AUTHORIZATION_SUCCESS; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Modifier; import java.net.URL; import java.util.ArrayList; import java.util.Collection; @@ -11,6 +16,7 @@ import java.util.Set; import java.util.function.BooleanSupplier; +import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.spi.EventContext; import org.jboss.jandex.AnnotationInstance; @@ -21,14 +27,21 @@ import org.jboss.jandex.MethodInfo; import org.jboss.logging.Logger; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; import io.opentelemetry.context.propagation.TextMapPropagator; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.quarkus.arc.Unremovable; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; import io.quarkus.arc.deployment.ObserverRegistrationPhaseBuildItem; import io.quarkus.arc.deployment.ObserverRegistrationPhaseBuildItem.ObserverConfiguratorBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; @@ -43,6 +56,8 @@ import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.ClassOutput; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; @@ -198,6 +213,110 @@ void registerSecurityEventObserver(Capabilities capabilities, OTelBuildConfig bu } } + /** + * Generates {@link SpanProcessor} that adds end-user attributes to new Spans. + * + * Generates: + * + * + * import io.quarkus.arc.Unremovable; + * import io.quarkus.opentelemetry.runtime.tracing.security.SecurityEventUtil; + * import jakarta.enterprise.context.Dependent; + * + * import io.opentelemetry.context.Context; + * import io.opentelemetry.sdk.trace.ReadWriteSpan; + * import io.opentelemetry.sdk.trace.ReadableSpan; + * import io.opentelemetry.sdk.trace.SpanProcessor; + * + * @Dependent + * @Unremovable + * public class EndUserSpanProcessor implements SpanProcessor { + * + * @Override + * public void onStart(Context parentContext, ReadWriteSpan span) { + * SecurityEventUtil.addEndUserAttributes(span); + * } + * + * @Override + * public boolean isStartRequired() { + * return Boolean.TRUE; + * } + * + * @Override + * public void onEnd(ReadableSpan span) { + * } + * + * @Override + * public boolean isEndRequired() { + * return Boolean.FALSE; + * } + * + * } + * + * + */ + @BuildStep(onlyIf = EndUserAttributesEnabled.class) + void generateEndUserAttributesSpanProcessor(BuildProducer generatedBeans, + Capabilities capabilities) { + if (capabilities.isPresent(Capability.SECURITY)) { + ClassOutput classOutput = new GeneratedBeanGizmoAdaptor(generatedBeans); + + try (ClassCreator classCreator = ClassCreator.builder().classOutput(classOutput) + .className("io.quarkus.opentelemetry.runtime.tracing.security.EndUserSpanProcessor") + .interfaces(SpanProcessor.class.getName()) + .build()) { + classCreator.addAnnotation(Dependent.class); + classCreator.addAnnotation(Unremovable.class); + + try (var methodCreator = classCreator.getMethodCreator("onStart", void.class, Context.class, + ReadWriteSpan.class)) { + methodCreator.setModifiers(Modifier.PUBLIC); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + + // SecurityEventUtil.addEndUserAttributes(span); + methodCreator.invokeStaticMethod( + MethodDescriptor.ofMethod(SecurityEventUtil.class, "addEndUserAttributes", + void.class, Span.class), + methodCreator.getMethodParam(1)); + + methodCreator.returnVoid(); + } + + try (var methodCreator = classCreator.getMethodCreator("isStartRequired", boolean.class)) { + methodCreator.setModifiers(Modifier.PUBLIC); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnBoolean(true); + } + + try (var methodCreator = classCreator.getMethodCreator("onEnd", void.class, ReadableSpan.class)) { + methodCreator.setModifiers(Modifier.PUBLIC); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnVoid(); + } + + try (var methodCreator = classCreator.getMethodCreator("isEndRequired", boolean.class)) { + methodCreator.setModifiers(Modifier.PUBLIC); + methodCreator.addAnnotation(Override.class.getName(), RetentionPolicy.CLASS); + methodCreator.returnBoolean(false); + } + } + } + } + + @BuildStep(onlyIf = EndUserAttributesEnabled.class) + void registerEndUserAttributesEventObserver(Capabilities capabilities, + ObserverRegistrationPhaseBuildItem observerRegistrationPhase, + BuildProducer observerProducer) { + if (capabilities.isPresent(Capability.SECURITY)) { + observerProducer + .produce(createEventObserver(observerRegistrationPhase, AUTHENTICATION_SUCCESS, "addEndUserAttributes")); + observerProducer + .produce(createEventObserver(observerRegistrationPhase, AUTHORIZATION_SUCCESS, "updateEndUserAttributes")); + observerProducer + .produce(createEventObserver(observerRegistrationPhase, AUTHORIZATION_FAILURE, "updateEndUserAttributes")); + } + } + private static ObserverConfiguratorBuildItem createEventObserver( ObserverRegistrationPhaseBuildItem observerRegistrationPhase, SecurityEventType eventType, String utilMethodName) { return new ObserverConfiguratorBuildItem(observerRegistrationPhase.getContext() @@ -232,4 +351,18 @@ public boolean getAsBoolean() { return enabled; } } + + static final class EndUserAttributesEnabled implements BooleanSupplier { + + private final boolean enabled; + + EndUserAttributesEnabled(OTelBuildConfig config) { + this.enabled = config.traces().addEndUserAttributes(); + } + + @Override + public boolean getAsBoolean() { + return enabled; + } + } } diff --git a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/config/build/TracesBuildConfig.java b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/config/build/TracesBuildConfig.java index 1c53611a14f1a4..d387253747d628 100644 --- a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/config/build/TracesBuildConfig.java +++ b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/config/build/TracesBuildConfig.java @@ -7,6 +7,7 @@ import io.quarkus.runtime.annotations.ConfigGroup; import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; /** * Tracing build time configuration @@ -51,4 +52,15 @@ public interface TracesBuildConfig { */ @WithDefault(SamplerType.Constants.PARENT_BASED_ALWAYS_ON) String sampler(); + + /** + * If OpenTelemetry End User attributes should be added as Span attributes on a best-efforts basis. + * + * @see OpenTelemetry End User + * attributes + */ + @WithName("eusp.enabled") + @WithDefault("false") + boolean addEndUserAttributes(); + } diff --git a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/security/SecurityEventUtil.java b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/security/SecurityEventUtil.java index 674ded182b2124..0efd0360901675 100644 --- a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/security/SecurityEventUtil.java +++ b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/security/SecurityEventUtil.java @@ -9,6 +9,8 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.semconv.SemanticAttributes; import io.quarkus.arc.Arc; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.spi.runtime.AuthenticationFailureEvent; @@ -16,10 +18,13 @@ import io.quarkus.security.spi.runtime.AuthorizationFailureEvent; import io.quarkus.security.spi.runtime.AuthorizationSuccessEvent; import io.quarkus.security.spi.runtime.SecurityEvent; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.ext.web.RoutingContext; /** * Synthetic CDI observers for various {@link SecurityEvent} types configured during the build time use this util class - * to export the events as the OpenTelemetry Span events. + * to export the events as the OpenTelemetry Span events, or authenticated user Span attributes. */ public final class SecurityEventUtil { public static final String QUARKUS_SECURITY_NAMESPACE = "quarkus.security."; @@ -38,8 +43,63 @@ private SecurityEventUtil() { // UTIL CLASS } + /** + * Adds Span attributes describing authenticated user if the user is authenticated and CDI request context is active. + * This will be true for example inside JAX-RS resources when the CDI request context is already setup and user code + * creates a new Span. + * + * WARNING: This method is called from generated bean. Any renaming must be reflected in the TracerProcessor. + * + * @param span valid and recording Span; must not be null + */ + public static void addEndUserAttributes(Span span) { + if (Arc.container().requestContext().isActive()) { + var currentVertxRequest = Arc.container().instance(CurrentVertxRequest.class).get(); + if (currentVertxRequest.getCurrent() != null) { + addEndUserAttribute(currentVertxRequest.getCurrent(), span); + } + } + } + + /** + * Updates authenticated user Span attributes if the {@link SecurityIdentity} got augmented during authorization. + * + * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. + * + * @param event {@link AuthorizationFailureEvent} + */ + public static void updateEndUserAttributes(AuthorizationFailureEvent event) { + addEndUserAttribute(event.getSecurityIdentity(), getSpan()); + } + + /** + * Updates authenticated user Span attributes if the {@link SecurityIdentity} got augmented during authorization. + * + * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. + * + * @param event {@link AuthorizationSuccessEvent} + */ + public static void updateEndUserAttributes(AuthorizationSuccessEvent event) { + addEndUserAttribute(event.getSecurityIdentity(), getSpan()); + } + + /** + * If there is already valid recording {@link Span}, attributes describing authenticated user are added to it. + * + * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. + * + * @param event {@link AuthenticationSuccessEvent} + */ + public static void addEndUserAttributes(AuthenticationSuccessEvent event) { + if (event.getEventProperties().get(RoutingContext.class.getName()) instanceof RoutingContext routingContext) { + addEndUserAttribute(event.getSecurityIdentity(), getSpan()); + } + + } + /** * Adds {@link SecurityEvent} as Span event. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addAllEvents(SecurityEvent event) { @@ -57,6 +117,8 @@ public static void addAllEvents(SecurityEvent event) { } /** + * Adds {@link AuthenticationSuccessEvent} as Span event. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addEvent(AuthenticationSuccessEvent event) { @@ -64,6 +126,8 @@ public static void addEvent(AuthenticationSuccessEvent event) { } /** + * Adds {@link AuthenticationFailureEvent} as Span event. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addEvent(AuthenticationFailureEvent event) { @@ -71,6 +135,8 @@ public static void addEvent(AuthenticationFailureEvent event) { } /** + * Adds {@link AuthorizationSuccessEvent} as Span event. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addEvent(AuthorizationSuccessEvent event) { @@ -79,6 +145,8 @@ public static void addEvent(AuthorizationSuccessEvent event) { } /** + * Adds {@link AuthorizationFailureEvent} as Span event. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addEvent(AuthorizationFailureEvent event) { @@ -88,6 +156,7 @@ public static void addEvent(AuthorizationFailureEvent event) { /** * Adds {@link SecurityEvent} as Span event that is not authN/authZ success/failure. + * * WARNING: This method is called from synthetic method observer. Any renaming must be reflected in the TracerProcessor. */ public static void addEvent(SecurityEvent event) { @@ -112,15 +181,14 @@ public void accept(String key, Object value) { } private static void addEvent(String eventName, Attributes attributes) { - Span span = Arc.container().select(Span.class).get(); - if (span.getSpanContext().isValid() && span.isRecording()) { + Span span = getSpan(); + if (spanIsValidAndRecording(span)) { span.addEvent(eventName, attributes, Instant.now()); } } private static AttributesBuilder attributesBuilder(SecurityEvent event, String failureKey) { - Throwable failure = (Throwable) event.getEventProperties().get(failureKey); - if (failure != null) { + if (event.getEventProperties().get(failureKey) instanceof Throwable failure) { return attributesBuilder(event).put(FAILURE_NAME, failure.getClass().getName()); } return attributesBuilder(event); @@ -146,4 +214,55 @@ private static Attributes withAuthorizationContext(SecurityEvent event, Attribut } return builder.build(); } + + /** + * Adds Span attributes describing the authenticated user. + * + * @param event {@link RoutingContext}; must not be null + * @param span valid recording Span; must not be null + */ + private static void addEndUserAttribute(RoutingContext event, Span span) { + if (event.user() instanceof QuarkusHttpUser user) { + addEndUserAttribute(user.getSecurityIdentity(), span); + } + } + + /** + * Adds End User attributes to the {@code span}. Only authenticated user is added to the {@link Span}. + * Anonymous identity is ignored as it does not represent authenticated user. + * Passed {@code securityIdentity} is attached to the {@link Context} so that we recognize when identity changes. + * + * @param securityIdentity SecurityIdentity + * @param span Span + */ + private static void addEndUserAttribute(SecurityIdentity securityIdentity, Span span) { + if (securityIdentity != null && !securityIdentity.isAnonymous() && spanIsValidAndRecording(span)) { + span.setAllAttributes(Attributes.of( + SemanticAttributes.ENDUSER_ID, + securityIdentity.getPrincipal().getName(), + SemanticAttributes.ENDUSER_ROLE, + getRoles(securityIdentity))); + } + } + + private static String getRoles(SecurityIdentity securityIdentity) { + try { + return securityIdentity.getRoles().toString(); + } catch (UnsupportedOperationException e) { + // getting roles is not supported when the identity is enhanced by custom jakarta.ws.rs.core.SecurityContext + return ""; + } + } + + private static Span getSpan() { + if (Arc.container().requestContext().isActive()) { + return Arc.container().select(Span.class).get(); + } else { + return Span.current(); + } + } + + private static boolean spanIsValidAndRecording(Span span) { + return span.isRecording() && span.getSpanContext().isValid(); + } } diff --git a/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/CustomSecurityIdentityAugmentor.java b/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/CustomSecurityIdentityAugmentor.java new file mode 100644 index 00000000000000..acf9bdd8df1cd6 --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/CustomSecurityIdentityAugmentor.java @@ -0,0 +1,43 @@ +package io.quarkus.it.opentelemetry.reactive; + +import java.util.Map; + +import jakarta.inject.Singleton; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; + +@Singleton +public class CustomSecurityIdentityAugmentor implements SecurityIdentityAugmentor { + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return augment(securityIdentity, authenticationRequestContext, Map.of()); + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context, + Map attributes) { + var routingContext = HttpSecurityUtils.getRoutingContextAttribute(attributes); + if (routingContext != null) { + var augmentorScenario = routingContext.normalizedPath().endsWith("-augmentor"); + var permitAllHttpPermScenario = routingContext.normalizedPath().contains("permit-all-http-perm"); + if (augmentorScenario || permitAllHttpPermScenario) { + var builder = QuarkusSecurityIdentity.builder(identity); + if (augmentorScenario) { + builder.addRole("PUBLISHER"); + } + if (permitAllHttpPermScenario) { + // this role is supposed to be re-mapped by HTTP roles mapping (not path-specific) + builder.addRole("PERMIT-ALL"); + } + return Uni.createFrom().item(builder.build()); + } + } + return Uni.createFrom().item(identity); + } +} diff --git a/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/EndUserResource.java b/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/EndUserResource.java new file mode 100644 index 00000000000000..8e4c0f3ca5f297 --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/main/java/io/quarkus/it/opentelemetry/reactive/EndUserResource.java @@ -0,0 +1,109 @@ +package io.quarkus.it.opentelemetry.reactive; + +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.semconv.SemanticAttributes; + +@Path("/otel/enduser") +public class EndUserResource { + + @Inject + Tracer tracer; + + @Path("/no-authorization") + @GET + public String noAuthorization() { + return "/no-authorization"; + } + + @RolesAllowed("WRITER") + @Path("/roles-allowed-only") + @GET + public String rolesAllowedOnly() { + return "/roles-allowed-only"; + } + + @PermitAll + @Path("/permit-all-only") + @GET + public String permitAllOnly() { + return "/permit-all-only"; + } + + @Path("/no-authorization-augmentor") + @GET + public String noAuthorizationAugmentor() { + return "/no-authorization-augmentor"; + } + + @RolesAllowed("PUBLISHER") + @Path("/roles-allowed-only-augmentor") + @GET + public String rolesAllowedOnlyAugmentor() { + return "/roles-allowed-only-augmentor"; + } + + @PermitAll + @Path("/permit-all-only-augmentor") + @GET + public String permitAllOnlyAugmentor() { + return "/permit-all-only-augmentor"; + } + + @RolesAllowed("WRITER-HTTP-PERM") + @Path("/roles-allowed-http-perm") + @GET + public String rolesAllowedHttpPerm() { + return "/roles-allowed-http-perm"; + } + + @PermitAll + @Path("/permit-all-http-perm") + @GET + public String permitAllHttpPerm() { + return "/permit-all-http-perm"; + } + + @RolesAllowed("PUBLISHER-HTTP-PERM") + @Path("/roles-allowed-http-perm-augmentor") + @GET + public String rolesAllowedHttpPermAugmentor() { + return "/roles-allowed-http-perm-augmentor"; + } + + @PermitAll + @Path("/permit-all-http-perm-augmentor") + @GET + public String permitAllHttpPermAugmentor() { + return "/permit-all-http-perm-augmentor"; + } + + @Path("/jax-rs-http-perm") + @GET + public String jaxRsHttpPermOnly() { + return "/jax-rs-http-perm"; + } + + @RolesAllowed("READER") + @Path("/jax-rs-http-perm-annotation") + @GET + public String jaxRsHttpPermRolesAllowed() { + return "/jax-rs-http-perm-annotation"; + } + + @RolesAllowed("READER") + @Path("/custom-span") + @GET + public String customSpan() { + var span = tracer.spanBuilder("custom-span").startSpan(); + span.setAttribute("custom_attribute", "custom-value"); + span.setAttribute(SemanticAttributes.HTTP_TARGET, "custom-path"); + span.end(); + return "/custom-span"; + } +} diff --git a/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/AbstractEndUserTest.java b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/AbstractEndUserTest.java new file mode 100644 index 00000000000000..710760beffa3f1 --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/AbstractEndUserTest.java @@ -0,0 +1,226 @@ +package io.quarkus.it.opentelemetry.reactive.enduser; + +import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import org.awaitility.Awaitility; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.opentelemetry.semconv.SemanticAttributes; +import io.quarkus.it.opentelemetry.reactive.Utils; + +public abstract class AbstractEndUserTest { + + protected static final String READER_ROLE = "READER"; + protected static final String WRITER_ROLE = "WRITER"; + protected static final String PUBLISHER_ROLE = "PUBLISHER"; + protected static final String PERMIT_ALL_ROLE = "PERMIT-ALL"; + private static final String END_USER_ID_ATTR = SemanticAttributes.ENDUSER_ID.getKey(); + private static final String END_USER_ROLE_ATTR = SemanticAttributes.ENDUSER_ROLE.getKey(); + private static final String HTTP_PERM_SUFFIX = "-HTTP-PERM"; + + @BeforeEach + @AfterEach + public void reset() { + given().get("/reset").then().statusCode(HTTP_OK); + await().atMost(5, SECONDS).until(() -> Utils.getSpans().isEmpty()); + } + + @Test + public void testAttributesWhenNoAuthorizationInPlace() { + assertEndUserAttributes("/no-authorization", isProactiveAuthEnabled(), User.SCOTT, null); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyUnauthorized() { + assertEndUserAttributes("/roles-allowed-only", true, User.SCOTT, WRITER_ROLE); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyAuthorized() { + assertEndUserAttributes("/roles-allowed-only", true, User.STUART, WRITER_ROLE); + } + + @Test + public void testWhenPermitAllOnly() { + assertEndUserAttributes("/permit-all-only", isProactiveAuthEnabled(), User.STUART, null); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyAuthorizedAugmentor() { + assertEndUserAttributes("/roles-allowed-only-augmentor", true, User.SCOTT, PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllOnlyAugmentor() { + assertEndUserAttributes("/permit-all-only-augmentor", isProactiveAuthEnabled(), User.STUART, PUBLISHER_ROLE); + } + + @Test + public void testAttributesWhenNoAuthorizationInPlaceAugmentor() { + assertEndUserAttributes("/no-authorization-augmentor", isProactiveAuthEnabled(), User.SCOTT, + PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllHttpPermAugmentor() { + assertEndUserAttributes("/permit-all-http-perm-augmentor", isProactiveAuthEnabled(), User.STUART, + null, PERMIT_ALL_ROLE + HTTP_PERM_SUFFIX, PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllHttpPerm() { + assertEndUserAttributes("/permit-all-http-perm", isProactiveAuthEnabled(), User.STUART, null, + PERMIT_ALL_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermUnauthorized() { + assertEndUserAttributes("/roles-allowed-http-perm", true, User.SCOTT, WRITER_ROLE, "AUTHZ-FAILURE-ROLE"); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermAuthorized() { + assertEndUserAttributes("/roles-allowed-http-perm", true, User.STUART, WRITER_ROLE, WRITER_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermAuthorizedAugmentor() { + assertEndUserAttributes("/roles-allowed-http-perm-augmentor", true, User.STUART, PUBLISHER_ROLE, + PUBLISHER_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testJaxRsHttpPermOnlyAuthorized() { + assertEndUserAttributes("/jax-rs-http-perm", true, User.STUART, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermOnlyUnauthorized() { + assertEndUserAttributes("/jax-rs-http-perm", true, User.SCOTT, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermAndRolesAllowedAnnotationAuthorized() { + assertEndUserAttributes("/jax-rs-http-perm-annotation", true, User.STUART, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermAndRolesAllowedAnnotationUnauthorized() { + assertEndUserAttributes("/jax-rs-http-perm-annotation", true, User.SCOTT, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testCustomSpanContainsEndUserAttributes() { + var path = "/custom-span"; + assertEndUserAttributes(path, true, User.SCOTT, READER_ROLE); + + // assert custom span also contains end user attributes + var spanData = waitForSpanWithPath("custom-path"); + assertEquals("custom-value", spanData.get("custom_attribute"), spanData.toString()); + assertEquals(User.SCOTT.userName(), spanData.get(END_USER_ID_ATTR), spanData.toString()); + String role = spanData.get(END_USER_ROLE_ATTR); + assertNotNull(role, spanData.toString()); + assertTrue(role.contains("READER"), spanData.toString()); + } + + protected void assertEndUserAttributes(String subPath, boolean expectEndUserAttrs, User requestUser, String requiredRole, + String... extraRoles) { + var path = "/otel/enduser" + subPath; + var response = given() + .when() + .auth().preemptive().basic(requestUser.userName(), requestUser.password) + .get(path) + .then(); + + boolean augmentorScenario = PUBLISHER_ROLE.equals(requiredRole); + boolean accessGranted = augmentorScenario || requiredRole == null || requestUser.roles.contains(requiredRole); + if (accessGranted) { + response.statusCode(200).body(is(subPath)); + } else { + response.statusCode(403); + } + + var spanData = waitForSpanWithPath(path); + if (expectEndUserAttrs) { + assertEquals(requestUser.userName(), spanData.get(END_USER_ID_ATTR), spanData.toString()); + String role = spanData.get(END_USER_ROLE_ATTR); + assertNotNull(role, spanData.toString()); + + if (augmentorScenario) { + assertTrue(role.contains(PUBLISHER_ROLE)); + assertTrue(role.contains(PUBLISHER_ROLE)); + } + + assertTrue(requestUser.roles.stream().allMatch(role::contains), spanData.toString()); + + for (String extraRole : extraRoles) { + assertTrue(role.contains(extraRole), spanData.toString()); + } + } else { + assertNull(spanData.get(END_USER_ID_ATTR), spanData.toString()); + assertNull(spanData.get(END_USER_ROLE_ATTR), spanData.toString()); + } + } + + protected Map waitForSpanWithPath(final String path) { + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> !getSpanByPath(path).isEmpty(), new TypeSafeMatcher<>() { + @Override + protected boolean matchesSafely(Boolean aBoolean) { + return Boolean.TRUE.equals(aBoolean); + } + + @Override + public void describeTo(Description description) { + description.appendText("Span with the 'http.target' attribute not found: " + path + " ; " + Utils.getSpans()); + } + }); + return getSpanByPath(path); + } + + private Map getSpanByPath(final String path) { + return Utils + .getSpans() + .stream() + .map(m -> (Map) m.get("attributes")) + .filter(m -> path.equals(m.get(SemanticAttributes.HTTP_TARGET.getKey()))) + .findFirst() + .orElse(Map.of()); + } + + protected abstract boolean isProactiveAuthEnabled(); + + public enum User { + + SCOTT("reader", Set.of(READER_ROLE)), + STUART("writer", Set.of(READER_ROLE, WRITER_ROLE)); + + private final String password; + private final Set roles; + + User(String password, Set roles) { + this.password = password; + this.roles = roles; + } + + private String userName() { + return this.toString().toLowerCase(); + } + } +} diff --git a/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EagerAuthEndUserEnabledTest.java b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EagerAuthEndUserEnabledTest.java new file mode 100644 index 00000000000000..1d894e70f92092 --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EagerAuthEndUserEnabledTest.java @@ -0,0 +1,15 @@ +package io.quarkus.it.opentelemetry.reactive.enduser; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(EndUserProfile.class) +public class EagerAuthEndUserEnabledTest extends AbstractEndUserTest { + + @Override + protected boolean isProactiveAuthEnabled() { + return true; + } + +} diff --git a/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EndUserProfile.java b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EndUserProfile.java new file mode 100644 index 00000000000000..0b8f1bf00abdce --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/EndUserProfile.java @@ -0,0 +1,33 @@ +package io.quarkus.it.opentelemetry.reactive.enduser; + +import java.util.HashMap; +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class EndUserProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(Map.of("quarkus.otel.traces.eusp.enabled", "true", + "quarkus.http.auth.permission.roles-1.policy", "role-policy-1", + "quarkus.http.auth.permission.roles-1.paths", "/otel/enduser/roles-allowed-http-perm", + "quarkus.http.auth.policy.role-policy-1.roles.WRITER", "WRITER-HTTP-PERM")); + config.put("quarkus.http.auth.policy.role-policy-1.roles.READER", "AUTHZ-FAILURE-ROLE"); + config.put("quarkus.http.auth.policy.role-policy-1.roles-allowed", "WRITER"); + config.put("quarkus.http.auth.permission.roles-2.policy", "role-policy-2"); + config.put("quarkus.http.auth.permission.roles-2.paths", "/otel/enduser/roles-allowed-http-perm-augmentor"); + config.put("quarkus.http.auth.policy.role-policy-2.roles-allowed", "PUBLISHER"); + config.put("quarkus.http.auth.policy.role-policy-2.roles.PUBLISHER", "PUBLISHER-HTTP-PERM"); + config.put("quarkus.http.auth.permission.jax-rs.policy", "jax-rs"); + config.put("quarkus.http.auth.permission.jax-rs.paths", + "/otel/enduser/jax-rs-http-perm,/otel/enduser/jax-rs-http-perm-annotation"); + config.put("quarkus.http.auth.permission.jax-rs.applies-to", "JAXRS"); + config.put("quarkus.http.auth.policy.jax-rs.roles-allowed", "WRITER"); + config.put("quarkus.http.auth.permission.permit-all.policy", "permit"); + config.put("quarkus.http.auth.permission.permit-all.paths", + "/otel/enduser/permit-all-http-perm,/otel/enduser/permit-all-http-perm-augmentor"); + config.put("quarkus.http.auth.roles-mapping.PERMIT-ALL", "PERMIT-ALL-HTTP-PERM"); + return config; + } +} diff --git a/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserEnabledTest.java b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserEnabledTest.java new file mode 100644 index 00000000000000..9e85525ea99d79 --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserEnabledTest.java @@ -0,0 +1,13 @@ +package io.quarkus.it.opentelemetry.reactive.enduser; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(LazyAuthEndUserProfile.class) +public class LazyAuthEndUserEnabledTest extends AbstractEndUserTest { + @Override + protected boolean isProactiveAuthEnabled() { + return false; + } +} diff --git a/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserProfile.java b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserProfile.java new file mode 100644 index 00000000000000..f62638221556bf --- /dev/null +++ b/integration-tests/opentelemetry-reactive/src/test/java/io/quarkus/it/opentelemetry/reactive/enduser/LazyAuthEndUserProfile.java @@ -0,0 +1,14 @@ +package io.quarkus.it.opentelemetry.reactive.enduser; + +import java.util.HashMap; +import java.util.Map; + +public class LazyAuthEndUserProfile extends EndUserProfile { + + @Override + public Map getConfigOverrides() { + var config = new HashMap<>(super.getConfigOverrides()); + config.put("quarkus.http.auth.proactive", "false"); + return config; + } +} diff --git a/integration-tests/opentelemetry/pom.xml b/integration-tests/opentelemetry/pom.xml index 903728882aee07..d3ac1983b71253 100644 --- a/integration-tests/opentelemetry/pom.xml +++ b/integration-tests/opentelemetry/pom.xml @@ -44,7 +44,7 @@ io.quarkus - quarkus-security + quarkus-elytron-security-properties-file @@ -59,11 +59,6 @@ quarkus-junit5 test - - io.quarkus - quarkus-test-security - test - io.rest-assured rest-assured @@ -130,7 +125,7 @@ io.quarkus - quarkus-security-deployment + quarkus-elytron-security-properties-file-deployment ${project.version} pom test diff --git a/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/CustomSecurityIdentityAugmentor.java b/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/CustomSecurityIdentityAugmentor.java new file mode 100644 index 00000000000000..8d9ae59363b7d1 --- /dev/null +++ b/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/CustomSecurityIdentityAugmentor.java @@ -0,0 +1,43 @@ +package io.quarkus.it.opentelemetry; + +import java.util.Map; + +import jakarta.inject.Singleton; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; + +@Singleton +public class CustomSecurityIdentityAugmentor implements SecurityIdentityAugmentor { + @Override + public Uni augment(SecurityIdentity securityIdentity, + AuthenticationRequestContext authenticationRequestContext) { + return augment(securityIdentity, authenticationRequestContext, Map.of()); + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context, + Map attributes) { + var routingContext = HttpSecurityUtils.getRoutingContextAttribute(attributes); + if (routingContext != null) { + var augmentorScenario = routingContext.normalizedPath().endsWith("-augmentor"); + var permitAllHttpPermScenario = routingContext.normalizedPath().contains("permit-all-http-perm"); + if (augmentorScenario || permitAllHttpPermScenario) { + var builder = QuarkusSecurityIdentity.builder(identity); + if (augmentorScenario) { + builder.addRole("PUBLISHER"); + } + if (permitAllHttpPermScenario) { + // this role is supposed to be re-mapped by HTTP roles mapping (not path-specific) + builder.addRole("PERMIT-ALL"); + } + return Uni.createFrom().item(builder.build()); + } + } + return Uni.createFrom().item(identity); + } +} diff --git a/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/EndUserResource.java b/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/EndUserResource.java new file mode 100644 index 00000000000000..a53e4409d2cdc4 --- /dev/null +++ b/integration-tests/opentelemetry/src/main/java/io/quarkus/it/opentelemetry/EndUserResource.java @@ -0,0 +1,109 @@ +package io.quarkus.it.opentelemetry; + +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.semconv.SemanticAttributes; + +@Path("/otel/enduser") +public class EndUserResource { + + @Inject + Tracer tracer; + + @Path("/no-authorization") + @GET + public String noAuthorization() { + return "/no-authorization"; + } + + @RolesAllowed("WRITER") + @Path("/roles-allowed-only") + @GET + public String rolesAllowedOnly() { + return "/roles-allowed-only"; + } + + @PermitAll + @Path("/permit-all-only") + @GET + public String permitAllOnly() { + return "/permit-all-only"; + } + + @Path("/no-authorization-augmentor") + @GET + public String noAuthorizationAugmentor() { + return "/no-authorization-augmentor"; + } + + @RolesAllowed("PUBLISHER") + @Path("/roles-allowed-only-augmentor") + @GET + public String rolesAllowedOnlyAugmentor() { + return "/roles-allowed-only-augmentor"; + } + + @PermitAll + @Path("/permit-all-only-augmentor") + @GET + public String permitAllOnlyAugmentor() { + return "/permit-all-only-augmentor"; + } + + @RolesAllowed("WRITER-HTTP-PERM") + @Path("/roles-allowed-http-perm") + @GET + public String rolesAllowedHttpPerm() { + return "/roles-allowed-http-perm"; + } + + @PermitAll + @Path("/permit-all-http-perm") + @GET + public String permitAllHttpPerm() { + return "/permit-all-http-perm"; + } + + @RolesAllowed("PUBLISHER-HTTP-PERM") + @Path("/roles-allowed-http-perm-augmentor") + @GET + public String rolesAllowedHttpPermAugmentor() { + return "/roles-allowed-http-perm-augmentor"; + } + + @PermitAll + @Path("/permit-all-http-perm-augmentor") + @GET + public String permitAllHttpPermAugmentor() { + return "/permit-all-http-perm-augmentor"; + } + + @Path("/jax-rs-http-perm") + @GET + public String jaxRsHttpPermOnly() { + return "/jax-rs-http-perm"; + } + + @RolesAllowed("READER") + @Path("/jax-rs-http-perm-annotation") + @GET + public String jaxRsHttpPermRolesAllowed() { + return "/jax-rs-http-perm-annotation"; + } + + @RolesAllowed("READER") + @Path("/custom-span") + @GET + public String customSpan() { + var span = tracer.spanBuilder("custom-span").startSpan(); + span.setAttribute("custom_attribute", "custom-value"); + span.setAttribute(SemanticAttributes.HTTP_TARGET, "custom-path"); + span.end(); + return "/custom-span"; + } +} diff --git a/integration-tests/opentelemetry/src/main/resources/application.properties b/integration-tests/opentelemetry/src/main/resources/application.properties index 054108ad58ab8c..514f563c31933a 100644 --- a/integration-tests/opentelemetry/src/main/resources/application.properties +++ b/integration-tests/opentelemetry/src/main/resources/application.properties @@ -8,3 +8,11 @@ quarkus.otel.bsp.export.timeout=5s pingpong/mp-rest/url=${test.url} simple/mp-rest/url=${test.url} + +quarkus.security.users.embedded.roles.stuart=READER,WRITER +quarkus.security.users.embedded.roles.scott=READER +quarkus.security.users.embedded.users.stuart=writer +quarkus.security.users.embedded.users.scott=reader +quarkus.security.users.embedded.plain-text=true +quarkus.security.users.embedded.enabled=true +quarkus.http.auth.basic=true diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/AbstractEndUserTest.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/AbstractEndUserTest.java new file mode 100644 index 00000000000000..1333bb39e0b009 --- /dev/null +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/AbstractEndUserTest.java @@ -0,0 +1,230 @@ +package io.quarkus.it.opentelemetry; + +import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.awaitility.Awaitility; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.opentelemetry.semconv.SemanticAttributes; +import io.restassured.common.mapper.TypeRef; + +public abstract class AbstractEndUserTest { + + protected static final String READER_ROLE = "READER"; + protected static final String WRITER_ROLE = "WRITER"; + protected static final String PUBLISHER_ROLE = "PUBLISHER"; + protected static final String PERMIT_ALL_ROLE = "PERMIT-ALL"; + private static final String END_USER_ID_ATTR = "attr_" + SemanticAttributes.ENDUSER_ID.getKey(); + private static final String END_USER_ROLE_ATTR = "attr_" + SemanticAttributes.ENDUSER_ROLE.getKey(); + private static final String HTTP_PERM_SUFFIX = "-HTTP-PERM"; + + @BeforeEach + @AfterEach + public void reset() { + given().get("/reset").then().statusCode(HTTP_OK); + await().atMost(5, SECONDS).until(() -> getSpans().isEmpty()); + } + + @Test + public void testAttributesWhenNoAuthorizationInPlace() { + assertEndUserAttributes("/no-authorization", isProactiveAuthEnabled(), User.SCOTT, null); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyUnauthorized() { + assertEndUserAttributes("/roles-allowed-only", true, User.SCOTT, WRITER_ROLE); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyAuthorized() { + assertEndUserAttributes("/roles-allowed-only", true, User.STUART, WRITER_ROLE); + } + + @Test + public void testWhenPermitAllOnly() { + assertEndUserAttributes("/permit-all-only", isProactiveAuthEnabled(), User.STUART, null); + } + + @Test + public void testWhenRolesAllowedAnnotationOnlyAuthorizedAugmentor() { + assertEndUserAttributes("/roles-allowed-only-augmentor", true, User.SCOTT, PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllOnlyAugmentor() { + assertEndUserAttributes("/permit-all-only-augmentor", isProactiveAuthEnabled(), User.STUART, PUBLISHER_ROLE); + } + + @Test + public void testAttributesWhenNoAuthorizationInPlaceAugmentor() { + assertEndUserAttributes("/no-authorization-augmentor", isProactiveAuthEnabled(), User.SCOTT, + PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllHttpPermAugmentor() { + assertEndUserAttributes("/permit-all-http-perm-augmentor", isProactiveAuthEnabled(), User.STUART, + null, PERMIT_ALL_ROLE + HTTP_PERM_SUFFIX, PUBLISHER_ROLE); + } + + @Test + public void testWhenPermitAllHttpPerm() { + assertEndUserAttributes("/permit-all-http-perm", isProactiveAuthEnabled(), User.STUART, null, + PERMIT_ALL_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermUnauthorized() { + assertEndUserAttributes("/roles-allowed-http-perm", true, User.SCOTT, WRITER_ROLE, "AUTHZ-FAILURE-ROLE"); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermAuthorized() { + assertEndUserAttributes("/roles-allowed-http-perm", true, User.STUART, WRITER_ROLE, WRITER_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testWhenRolesAllowedAnnotationHttpPermAuthorizedAugmentor() { + assertEndUserAttributes("/roles-allowed-http-perm-augmentor", true, User.STUART, PUBLISHER_ROLE, + PUBLISHER_ROLE + HTTP_PERM_SUFFIX); + } + + @Test + public void testJaxRsHttpPermOnlyAuthorized() { + assertEndUserAttributes("/jax-rs-http-perm", true, User.STUART, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermOnlyUnauthorized() { + assertEndUserAttributes("/jax-rs-http-perm", true, User.SCOTT, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermAndRolesAllowedAnnotationAuthorized() { + assertEndUserAttributes("/jax-rs-http-perm-annotation", true, User.STUART, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testJaxRsHttpPermAndRolesAllowedAnnotationUnauthorized() { + assertEndUserAttributes("/jax-rs-http-perm-annotation", true, User.SCOTT, WRITER_ROLE, READER_ROLE); + } + + @Test + public void testCustomSpanContainsEndUserAttributes() { + var path = "/custom-span"; + assertEndUserAttributes(path, true, User.SCOTT, READER_ROLE); + + // assert custom span also contains end user attributes + var spanData = waitForSpanWithPath("custom-path"); + assertEquals("custom-value", spanData.get("attr_custom_attribute"), spanData.toString()); + assertEquals(User.SCOTT.userName(), spanData.get(END_USER_ID_ATTR), spanData.toString()); + String role = (String) spanData.get(END_USER_ROLE_ATTR); + assertNotNull(role, spanData.toString()); + assertTrue(role.contains("READER"), spanData.toString()); + } + + protected void assertEndUserAttributes(String subPath, boolean expectEndUserAttrs, User requestUser, String requiredRole, + String... extraRoles) { + var path = "/otel/enduser" + subPath; + var response = given() + .when() + .auth().preemptive().basic(requestUser.userName(), requestUser.password) + .get(path) + .then(); + + boolean augmentorScenario = PUBLISHER_ROLE.equals(requiredRole); + boolean accessGranted = augmentorScenario || requiredRole == null || requestUser.roles.contains(requiredRole); + if (accessGranted) { + response.statusCode(200).body(is(subPath)); + } else { + response.statusCode(403); + } + + var spanData = waitForSpanWithPath(path); + if (expectEndUserAttrs) { + assertEquals(requestUser.userName(), spanData.get(END_USER_ID_ATTR), spanData.toString()); + String role = (String) spanData.get(END_USER_ROLE_ATTR); + assertNotNull(role, spanData.toString()); + + if (augmentorScenario) { + assertTrue(role.contains(PUBLISHER_ROLE)); + assertTrue(role.contains(PUBLISHER_ROLE)); + } + + assertTrue(requestUser.roles.stream().allMatch(role::contains), spanData.toString()); + + for (String extraRole : extraRoles) { + assertTrue(role.contains(extraRole), spanData.toString()); + } + } else { + assertNull(spanData.get(END_USER_ID_ATTR), spanData.toString()); + assertNull(spanData.get(END_USER_ROLE_ATTR), spanData.toString()); + } + } + + private List> getSpans() { + return get("/export").body().as(new TypeRef<>() { + }); + } + + protected Map waitForSpanWithPath(final String path) { + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> !getSpanByPath(path).isEmpty(), new TypeSafeMatcher<>() { + @Override + protected boolean matchesSafely(Boolean aBoolean) { + return Boolean.TRUE.equals(aBoolean); + } + + @Override + public void describeTo(Description description) { + description.appendText("Span with the 'http.target' attribute not found: " + path + " ; " + getSpans()); + } + }); + return getSpanByPath(path); + } + + private Map getSpanByPath(final String path) { + return getSpans() + .stream() + .filter(m -> path.equals(m.get("attr_" + SemanticAttributes.HTTP_TARGET.getKey()))) + .findFirst() + .orElse(Map.of()); + } + + protected abstract boolean isProactiveAuthEnabled(); + + public enum User { + + SCOTT("reader", Set.of(READER_ROLE)), + STUART("writer", Set.of(READER_ROLE, WRITER_ROLE)); + + private final String password; + private final Set roles; + + User(String password, Set roles) { + this.password = password; + this.roles = roles; + } + + private String userName() { + return this.toString().toLowerCase(); + } + } +} diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/EagerAuthEndUserEnabledTest.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/EagerAuthEndUserEnabledTest.java new file mode 100644 index 00000000000000..37d7c40b319b17 --- /dev/null +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/EagerAuthEndUserEnabledTest.java @@ -0,0 +1,16 @@ +package io.quarkus.it.opentelemetry; + +import io.quarkus.it.opentelemetry.util.EndUserProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(EndUserProfile.class) +public class EagerAuthEndUserEnabledTest extends AbstractEndUserTest { + + @Override + protected boolean isProactiveAuthEnabled() { + return true; + } + +} diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/LazyAuthEndUserEnabledTest.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/LazyAuthEndUserEnabledTest.java new file mode 100644 index 00000000000000..82f4d329840efb --- /dev/null +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/LazyAuthEndUserEnabledTest.java @@ -0,0 +1,14 @@ +package io.quarkus.it.opentelemetry; + +import io.quarkus.it.opentelemetry.util.LazyAuthEndUserProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(LazyAuthEndUserProfile.class) +public class LazyAuthEndUserEnabledTest extends AbstractEndUserTest { + @Override + protected boolean isProactiveAuthEnabled() { + return false; + } +} diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/OpenTelemetryTest.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/OpenTelemetryTest.java index a8df12fd2304b8..6d992095fade7e 100644 --- a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/OpenTelemetryTest.java +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/OpenTelemetryTest.java @@ -27,7 +27,9 @@ import java.util.Map; import java.util.concurrent.TimeUnit; +import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,6 +40,7 @@ import io.opentelemetry.api.trace.TraceId; import io.opentelemetry.context.Context; import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.semconv.SemanticAttributes; import io.quarkus.it.opentelemetry.util.SocketClient; import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; @@ -705,6 +708,34 @@ void testWrongHTTPVersion() { await().atMost(5, TimeUnit.SECONDS).until(() -> getSpans().size() == 1); } + /** + * Test no End User attributes are added when the feature is disabled. + */ + @Test + public void testNoEndUserAttributes() { + RestAssured + .given() + .auth().preemptive().basic("stuart", "writer") + .get("/otel/enduser/roles-allowed-only") + .then() + .statusCode(200) + .body(Matchers.is("/roles-allowed-only")); + RestAssured + .given() + .auth().preemptive().basic("scott", "reader") + .get("/otel/enduser/roles-allowed-only") + .then() + .statusCode(403); + await().atMost(5, TimeUnit.SECONDS).until(() -> getSpans().size() > 1); + List> spans = getSpans(); + Assertions.assertTrue(spans + .stream() + .flatMap(m -> m.entrySet().stream()) + .filter(e -> ("attr_" + SemanticAttributes.ENDUSER_ID.getKey()).equals(e.getKey()) + || ("attr_" + SemanticAttributes.ENDUSER_ROLE.getKey()).equals(e.getKey())) + .findAny().isEmpty()); + } + private void verifyResource(Map spanData) { assertEquals("opentelemetry-integration-test", spanData.get("resource_service.name")); assertEquals("999-SNAPSHOT", spanData.get("resource_service.version")); diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/EndUserProfile.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/EndUserProfile.java new file mode 100644 index 00000000000000..4bb243bb648d62 --- /dev/null +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/EndUserProfile.java @@ -0,0 +1,33 @@ +package io.quarkus.it.opentelemetry.util; + +import java.util.HashMap; +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class EndUserProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(Map.of("quarkus.otel.traces.eusp.enabled", "true", + "quarkus.http.auth.permission.roles-1.policy", "role-policy-1", + "quarkus.http.auth.permission.roles-1.paths", "/otel/enduser/roles-allowed-http-perm", + "quarkus.http.auth.policy.role-policy-1.roles.WRITER", "WRITER-HTTP-PERM")); + config.put("quarkus.http.auth.policy.role-policy-1.roles.READER", "AUTHZ-FAILURE-ROLE"); + config.put("quarkus.http.auth.policy.role-policy-1.roles-allowed", "WRITER"); + config.put("quarkus.http.auth.permission.roles-2.policy", "role-policy-2"); + config.put("quarkus.http.auth.permission.roles-2.paths", "/otel/enduser/roles-allowed-http-perm-augmentor"); + config.put("quarkus.http.auth.policy.role-policy-2.roles-allowed", "PUBLISHER"); + config.put("quarkus.http.auth.policy.role-policy-2.roles.PUBLISHER", "PUBLISHER-HTTP-PERM"); + config.put("quarkus.http.auth.permission.jax-rs.policy", "jax-rs"); + config.put("quarkus.http.auth.permission.jax-rs.paths", + "/otel/enduser/jax-rs-http-perm,/otel/enduser/jax-rs-http-perm-annotation"); + config.put("quarkus.http.auth.permission.jax-rs.applies-to", "JAXRS"); + config.put("quarkus.http.auth.policy.jax-rs.roles-allowed", "WRITER"); + config.put("quarkus.http.auth.permission.permit-all.policy", "permit"); + config.put("quarkus.http.auth.permission.permit-all.paths", + "/otel/enduser/permit-all-http-perm,/otel/enduser/permit-all-http-perm-augmentor"); + config.put("quarkus.http.auth.roles-mapping.PERMIT-ALL", "PERMIT-ALL-HTTP-PERM"); + return config; + } +} diff --git a/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/LazyAuthEndUserProfile.java b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/LazyAuthEndUserProfile.java new file mode 100644 index 00000000000000..471bce77e748bf --- /dev/null +++ b/integration-tests/opentelemetry/src/test/java/io/quarkus/it/opentelemetry/util/LazyAuthEndUserProfile.java @@ -0,0 +1,14 @@ +package io.quarkus.it.opentelemetry.util; + +import java.util.HashMap; +import java.util.Map; + +public class LazyAuthEndUserProfile extends EndUserProfile { + + @Override + public Map getConfigOverrides() { + var config = new HashMap<>(super.getConfigOverrides()); + config.put("quarkus.http.auth.proactive", "false"); + return config; + } +}