Browse Source

Capture enduser attributes in Spring Security (#9777)

Phil Clay 1 year ago
parent
commit
6ed32390bc
26 changed files with 1387 additions and 8 deletions
  1. 1 1
      instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/BaseServletHelper.java
  2. 1 1
      instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/ServletAdditionalAttributesExtractor.java
  3. 14 0
      instrumentation/spring/spring-security-config-6.0/javaagent/README.md
  4. 38 0
      instrumentation/spring/spring-security-config-6.0/javaagent/build.gradle.kts
  5. 46 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturerSingletons.java
  6. 46 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/HttpSecurityInstrumentation.java
  7. 54 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/SpringSecurityConfigServletInstrumentationModule.java
  8. 46 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/ServerHttpSecurityInstrumentation.java
  9. 42 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/SpringSecurityConfigWebFluxInstrumentationModule.java
  10. 62 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/HttpSecurityInstrumentationTest.java
  11. 38 0
      instrumentation/spring/spring-security-config-6.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/ServerHttpSecurityInstrumentationTest.java
  12. 77 0
      instrumentation/spring/spring-security-config-6.0/library/README.md
  13. 20 0
      instrumentation/spring/spring-security-config-6.0/library/build.gradle.kts
  14. 150 0
      instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturer.java
  15. 46 0
      instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesCapturingServletFilter.java
  16. 37 0
      instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesHttpSecurityCustomizer.java
  17. 51 0
      instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesCapturingWebFilter.java
  18. 34 0
      instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesServerHttpSecurityCustomizer.java
  19. 217 0
      instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturerTest.java
  20. 82 0
      instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesCapturingServletFilterTest.java
  21. 61 0
      instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesHttpSecurityCustomizerTest.java
  22. 80 0
      instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesCapturingWebFilterTest.java
  23. 39 0
      instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesServerHttpSecurityCustomizerTest.java
  24. 6 6
      javaagent-extension-api/src/main/java/io/opentelemetry/javaagent/bootstrap/internal/CommonConfig.java
  25. 97 0
      javaagent-extension-api/src/main/java/io/opentelemetry/javaagent/bootstrap/internal/EnduserConfig.java
  26. 2 0
      settings.gradle.kts

+ 1 - 1
instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/BaseServletHelper.java

@@ -136,7 +136,7 @@ public abstract class BaseServletHelper<REQUEST, RESPONSE> {
    * created by servlet instrumentation we call this method on exit from the last servlet or filter.
    */
   private void captureEnduserId(Span serverSpan, REQUEST request) {
-    if (!CommonConfig.get().shouldCaptureEnduser()) {
+    if (!CommonConfig.get().getEnduserConfig().isIdEnabled()) {
       return;
     }
 

+ 1 - 1
instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/ServletAdditionalAttributesExtractor.java

@@ -44,7 +44,7 @@ public class ServletAdditionalAttributesExtractor<REQUEST, RESPONSE>
       ServletRequestContext<REQUEST> requestContext,
       @Nullable ServletResponseContext<RESPONSE> responseContext,
       @Nullable Throwable error) {
-    if (CommonConfig.get().shouldCaptureEnduser()) {
+    if (CommonConfig.get().getEnduserConfig().isIdEnabled()) {
       Principal principal = accessor.getRequestUserPrincipal(requestContext.request());
       if (principal != null) {
         String name = principal.getName();

+ 14 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/README.md

@@ -0,0 +1,14 @@
+# OpenTelemetry Javaagent Instrumentation: Spring Security Config
+
+Javaagent automatic instrumentation to capture `enduser.*` semantic attributes
+from Spring Security `Authentication` objects.
+
+## Settings
+
+This module honors the [common `otel.instrumentation.common.enduser.*` properties](https://opentelemetry.io/docs/instrumentation/java/automatic/agent-config/#common-instrumentation-configuration)
+and the following properties:
+
+| Property                                                                      | Type    | Default  | Description                                                                                             |
+|-------------------------------------------------------------------------------|---------|----------|---------------------------------------------------------------------------------------------------------|
+| `otel.instrumentation.spring-security.enduser.role.granted-authority-prefix`  | String  | `ROLE_`  | Prefix of granted authorities identifying roles to capture in the `enduser.role` semantic attribute.    |
+| `otel.instrumentation.spring-security.enduser.scope.granted-authority-prefix` | String  | `SCOPE_` | Prefix of granted authorities identifying scopes to capture in the `enduser.scopes` semantic attribute. |

+ 38 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/build.gradle.kts

@@ -0,0 +1,38 @@
+plugins {
+  id("otel.javaagent-instrumentation")
+}
+
+muzzle {
+  pass {
+    group.set("org.springframework.security")
+    module.set("spring-security-config")
+    versions.set("[6.0.0,]")
+
+    extraDependency("jakarta.servlet:jakarta.servlet-api:6.0.0")
+    extraDependency("org.springframework.security:spring-security-web:6.0.0")
+    extraDependency("io.projectreactor:reactor-core:3.5.0")
+  }
+}
+
+dependencies {
+  implementation(project(":instrumentation:spring:spring-security-config-6.0:library"))
+
+  library("org.springframework.security:spring-security-config:6.0.0")
+  library("org.springframework.security:spring-security-web:6.0.0")
+  library("io.projectreactor:reactor-core:3.5.0")
+
+  testLibrary("org.springframework:spring-test:6.0.0")
+  testLibrary("jakarta.servlet:jakarta.servlet-api:6.0.0")
+}
+
+otelJava {
+  minJavaVersionSupported.set(JavaVersion.VERSION_17)
+}
+
+tasks {
+  test {
+    systemProperty("otel.instrumentation.common.enduser.id.enabled", "true")
+    systemProperty("otel.instrumentation.common.enduser.role.enabled", "true")
+    systemProperty("otel.instrumentation.common.enduser.scope.enabled", "true")
+  }
+}

+ 46 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturerSingletons.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import io.opentelemetry.javaagent.bootstrap.internal.CommonConfig;
+import io.opentelemetry.javaagent.bootstrap.internal.InstrumentationConfig;
+
+public class EnduserAttributesCapturerSingletons {
+
+  private static final EnduserAttributesCapturer ENDUSER_ATTRIBUTES_CAPTURER =
+      createEndUserAttributesCapturerFromConfig();
+
+  private EnduserAttributesCapturerSingletons() {}
+
+  public static EnduserAttributesCapturer enduserAttributesCapturer() {
+    return ENDUSER_ATTRIBUTES_CAPTURER;
+  }
+
+  private static EnduserAttributesCapturer createEndUserAttributesCapturerFromConfig() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(CommonConfig.get().getEnduserConfig().isIdEnabled());
+    capturer.setEnduserRoleEnabled(CommonConfig.get().getEnduserConfig().isRoleEnabled());
+    capturer.setEnduserScopeEnabled(CommonConfig.get().getEnduserConfig().isScopeEnabled());
+
+    String rolePrefix =
+        InstrumentationConfig.get()
+            .getString(
+                "otel.instrumentation.spring-security.enduser.role.granted-authority-prefix");
+    if (rolePrefix != null) {
+      capturer.setRoleGrantedAuthorityPrefix(rolePrefix);
+    }
+
+    String scopePrefix =
+        InstrumentationConfig.get()
+            .getString(
+                "otel.instrumentation.spring-security.enduser.scope.granted-authority-prefix");
+    if (scopePrefix != null) {
+      capturer.setScopeGrantedAuthorityPrefix(rolePrefix);
+    }
+    return capturer;
+  }
+}

+ 46 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/HttpSecurityInstrumentation.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.servlet;
+
+import static io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturerSingletons.enduserAttributesCapturer;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.isProtected;
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet.EnduserAttributesHttpSecurityCustomizer;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+
+/** Instrumentation for {@link HttpSecurity}. */
+public class HttpSecurityInstrumentation implements TypeInstrumentation {
+
+  @Override
+  public ElementMatcher<TypeDescription> typeMatcher() {
+    return named("org.springframework.security.config.annotation.web.builders.HttpSecurity");
+  }
+
+  @Override
+  public void transform(TypeTransformer transformer) {
+    transformer.applyAdviceToMethod(
+        isMethod().and(isProtected()).and(named("performBuild")).and(takesArguments(0)),
+        getClass().getName() + "$PerformBuildAdvice");
+  }
+
+  @SuppressWarnings("unused")
+  public static class PerformBuildAdvice {
+
+    @Advice.OnMethodEnter(suppress = Throwable.class)
+    public static void onEnter(@Advice.This HttpSecurity httpSecurity) {
+      new EnduserAttributesHttpSecurityCustomizer(enduserAttributesCapturer())
+          .customize(httpSecurity);
+    }
+  }
+}

+ 54 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/SpringSecurityConfigServletInstrumentationModule.java

@@ -0,0 +1,54 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.servlet;
+
+import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasClassesNamed;
+import static java.util.Collections.singletonList;
+
+import com.google.auto.service.AutoService;
+import io.opentelemetry.javaagent.bootstrap.internal.CommonConfig;
+import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
+import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
+import java.util.List;
+import net.bytebuddy.matcher.ElementMatcher;
+
+/** Instrumentation module for servlet-based applications that use spring-security-config. */
+@AutoService(InstrumentationModule.class)
+public class SpringSecurityConfigServletInstrumentationModule extends InstrumentationModule {
+  public SpringSecurityConfigServletInstrumentationModule() {
+    super("spring-security-config-servlet", "spring-security-config-servlet-6.0");
+  }
+
+  @Override
+  public boolean defaultEnabled(ConfigProperties config) {
+    return super.defaultEnabled(config)
+        /*
+         * Since the only thing this module currently does is capture enduser attributes,
+         * the module can be completely disabled if enduser attributes are disabled.
+         *
+         * If any functionality not related to enduser attributes is added to this module,
+         * then this check will need to move elsewhere to only guard the enduser attributes logic.
+         */
+        && CommonConfig.get().getEnduserConfig().isAnyEnabled();
+  }
+
+  @Override
+  public ElementMatcher.Junction<ClassLoader> classLoaderMatcher() {
+    /*
+     * Ensure this module is only applied to Spring Security >= 6.0,
+     * since Spring Security >= 6.0 uses Jakarta EE rather than Java EE,
+     * and this instrumentation module uses Jakarta EE.
+     */
+    return hasClassesNamed(
+        "org.springframework.security.authentication.ObservationAuthenticationManager");
+  }
+
+  @Override
+  public List<TypeInstrumentation> typeInstrumentations() {
+    return singletonList(new HttpSecurityInstrumentation());
+  }
+}

+ 46 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/ServerHttpSecurityInstrumentation.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.webflux;
+
+import static io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturerSingletons.enduserAttributesCapturer;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.isPublic;
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux.EnduserAttributesServerHttpSecurityCustomizer;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+
+/** Instrumentation for {@link ServerHttpSecurity}. */
+public class ServerHttpSecurityInstrumentation implements TypeInstrumentation {
+
+  @Override
+  public ElementMatcher<TypeDescription> typeMatcher() {
+    return named("org.springframework.security.config.web.server.ServerHttpSecurity");
+  }
+
+  @Override
+  public void transform(TypeTransformer transformer) {
+    transformer.applyAdviceToMethod(
+        isMethod().and(isPublic()).and(named("build")).and(takesArguments(0)),
+        getClass().getName() + "$BuildAdvice");
+  }
+
+  @SuppressWarnings("unused")
+  public static class BuildAdvice {
+
+    @Advice.OnMethodEnter(suppress = Throwable.class)
+    public static void onEnter(@Advice.This ServerHttpSecurity serverHttpSecurity) {
+      new EnduserAttributesServerHttpSecurityCustomizer(enduserAttributesCapturer())
+          .customize(serverHttpSecurity);
+    }
+  }
+}

+ 42 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/SpringSecurityConfigWebFluxInstrumentationModule.java

@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.webflux;
+
+import static java.util.Collections.singletonList;
+
+import com.google.auto.service.AutoService;
+import io.opentelemetry.javaagent.bootstrap.internal.CommonConfig;
+import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule;
+import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
+import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
+import java.util.List;
+
+/** Instrumentation module for webflux-based applications that use spring-security-config. */
+@AutoService(InstrumentationModule.class)
+public class SpringSecurityConfigWebFluxInstrumentationModule extends InstrumentationModule {
+
+  public SpringSecurityConfigWebFluxInstrumentationModule() {
+    super("spring-security-config-webflux", "spring-security-config-webflux-6.0");
+  }
+
+  @Override
+  public boolean defaultEnabled(ConfigProperties config) {
+    return super.defaultEnabled(config)
+        /*
+         * Since the only thing this module currently does is capture enduser attributes,
+         * the module can be completely disabled if enduser attributes are disabled.
+         *
+         * If any functionality not related to enduser attributes is added to this module,
+         * then this check will need to move elsewhere to only guard the enduser attributes logic.
+         */
+        && CommonConfig.get().getEnduserConfig().isAnyEnabled();
+  }
+
+  @Override
+  public List<TypeInstrumentation> typeInstrumentations() {
+    return singletonList(new ServerHttpSecurityInstrumentation());
+  }
+}

+ 62 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/servlet/HttpSecurityInstrumentationTest.java

@@ -0,0 +1,62 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.servlet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet.EnduserAttributesCapturingServletFilter;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.ObjectPostProcessor;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.DefaultSecurityFilterChain;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SpringExtension.class)
+class HttpSecurityInstrumentationTest {
+
+  @Configuration
+  static class TestConfiguration {}
+
+  @Mock ObjectPostProcessor<Object> objectPostProcessor;
+
+  /**
+   * Ensures that {@link HttpSecurityInstrumentation} registers a {@link
+   * EnduserAttributesCapturingServletFilter} in the filter chain.
+   *
+   * <p>Usage of the filter is covered in other unit tests.
+   */
+  @Test
+  void ensureFilterRegistered(@Autowired ApplicationContext applicationContext) throws Exception {
+
+    AuthenticationManagerBuilder authenticationBuilder =
+        new AuthenticationManagerBuilder(objectPostProcessor);
+
+    HttpSecurity httpSecurity =
+        new HttpSecurity(
+            objectPostProcessor,
+            authenticationBuilder,
+            Collections.singletonMap(ApplicationContext.class, applicationContext));
+
+    DefaultSecurityFilterChain filterChain = httpSecurity.build();
+
+    assertThat(filterChain.getFilters())
+        .filteredOn(
+            item ->
+                item.getClass()
+                    .getName()
+                    .endsWith(EnduserAttributesCapturingServletFilter.class.getSimpleName()))
+        .hasSize(1);
+  }
+}

+ 38 - 0
instrumentation/spring/spring-security-config-6.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/spring/security/config/v6_0/webflux/ServerHttpSecurityInstrumentationTest.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.spring.security.config.v6_0.webflux;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux.EnduserAttributesCapturingWebFilter;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+
+class ServerHttpSecurityInstrumentationTest {
+
+  /**
+   * Ensures that {@link ServerHttpSecurityInstrumentation} registers a {@link
+   * EnduserAttributesCapturingWebFilter} in the filter chain.
+   *
+   * <p>Usage of the filter is covered in other unit tests.
+   */
+  @Test
+  void ensureFilterRegistered() {
+
+    ServerHttpSecurity serverHttpSecurity = ServerHttpSecurity.http();
+
+    SecurityWebFilterChain securityWebFilterChain = serverHttpSecurity.build();
+
+    assertThat(securityWebFilterChain.getWebFilters().collectList().block())
+        .filteredOn(
+            item ->
+                item.getClass()
+                    .getName()
+                    .endsWith(EnduserAttributesCapturingWebFilter.class.getSimpleName()))
+        .hasSize(1);
+  }
+}

+ 77 - 0
instrumentation/spring/spring-security-config-6.0/library/README.md

@@ -0,0 +1,77 @@
+# OpenTelemetry Instrumentation: Spring Security Config
+
+Provides a Servlet `Filter` and a WebFlux `WebFilter` to capture `enduser.*` semantic attributes
+from Spring Security `Authentication` objects.
+
+Also provides `Customizer` implementations to insert those filters into the filter chains created by
+`HttpSecurity` and `ServerHttpSecurity`, respectively.
+
+## Usage in Spring WebMVC Applications
+
+When not using [automatic instrumentation](../javaagent/), you can enable enduser attribute capturing
+for a `SecurityFilterChain` by appling an `EnduserAttributesHttpSecurityCustomizer`
+to the `HttpSecurity` which constructs the `SecurityFilterChain`.
+
+```java
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet.EnduserAttributesHttpSecurityCustomizer;
+
+@Configuration
+@EnableWebSecurity
+class MyWebSecurityConfig {
+
+  @Bean
+  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+    // First, apply application related configuration to http
+
+    // Then, apply enduser.* attribute capturing
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    // Set properties of capturer.  Defaults shown.
+    capturer.setEnduserIdEnabled(false);
+    capturer.setEnduserRoleEnabled(false);
+    capturer.setEnduserScopeEnabled(false);
+    capturer.setRoleGrantedAuthorityPrefix("ROLE_");
+    capturer.setScopeGrantedAuthorityPrefix("SCOPE_");
+
+    new EnduserAttributesHttpSecurityCustomizer(capturer)
+        .customize(http);
+
+    return http.build();
+  }
+}
+```
+
+## Usage in Spring WebFlux Applications
+
+When not using [automatic instrumentation](../javaagent/), you can enable enduser attribute capturing
+for a `SecurityWebFilterChain` by appling an `EnduserAttributesServerHttpSecurityCustomizer`
+to the `ServerHttpSecurity` which constructs the `SecurityWebFilterChain`.
+
+```java
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux.EnduserAttributesServerHttpSecurityCustomizer;
+
+@Configuration
+@EnableWebFluxSecurity
+class MyWebFluxSecurityConfig {
+
+  @Bean
+  public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
+    // First, apply application related configuration to http
+
+    // Then, apply enduser.* attribute capturing
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    // Set properties of capturer.  Defaults shown.
+    capturer.setEnduserIdEnabled(false);
+    capturer.setEnduserRoleEnabled(false);
+    capturer.setEnduserScopeEnabled(false);
+    capturer.setRoleGrantedAuthorityPrefix("ROLE_");
+    capturer.setScopeGrantedAuthorityPrefix("SCOPE_");
+
+    new EnduserAttributesServerHttpSecurityCustomizer(capturer)
+        .customize(http);
+
+    return http.build();
+  }
+}
+```

+ 20 - 0
instrumentation/spring/spring-security-config-6.0/library/build.gradle.kts

@@ -0,0 +1,20 @@
+plugins {
+  id("otel.library-instrumentation")
+}
+
+dependencies {
+  library("org.springframework.security:spring-security-config:6.0.0")
+  library("org.springframework.security:spring-security-web:6.0.0")
+  library("org.springframework:spring-web:6.0.0")
+  library("io.projectreactor:reactor-core:3.5.0")
+  library("jakarta.servlet:jakarta.servlet-api:6.0.0")
+
+  implementation(project(":instrumentation:reactor:reactor-3.1:library"))
+
+  testImplementation(project(":testing-common"))
+  testImplementation("org.springframework:spring-test:6.0.0")
+}
+
+otelJava {
+  minJavaVersionSupported.set(JavaVersion.VERSION_17)
+}

+ 150 - 0
instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturer.java

@@ -0,0 +1,150 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0;
+
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.api.instrumenter.LocalRootSpan;
+import io.opentelemetry.semconv.SemanticAttributes;
+import java.util.Objects;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+
+/**
+ * Captures {@code enduser.*} semantic attributes from {@link Authentication} objects.
+ *
+ * <p>After construction, you must selectively enable which attributes you want captured by calling
+ * the appropriate {@code setEnduser*Enabled(true)} method.
+ */
+public final class EnduserAttributesCapturer {
+
+  private static final String DEFAULT_ROLE_PREFIX = "ROLE_";
+  private static final String DEFAULT_SCOPE_PREFIX = "SCOPE_";
+
+  /** Determines if the {@code enduser.id} attribute should be captured. */
+  private boolean enduserIdEnabled;
+
+  /** Determines if the {@code enduser.role} attribute should be captured. */
+  private boolean enduserRoleEnabled;
+
+  /** Determines if the {@code enduser.scope} attribute should be captured. */
+  private boolean enduserScopeEnabled;
+
+  /** The prefix used to find {@link GrantedAuthority} objects for roles. */
+  private String roleGrantedAuthorityPrefix = DEFAULT_ROLE_PREFIX;
+
+  /** The prefix used to find {@link GrantedAuthority} objects for scopes. */
+  private String scopeGrantedAuthorityPrefix = DEFAULT_SCOPE_PREFIX;
+
+  /**
+   * Captures the {@code enduser.*} semantic attributes from the given {@link Authentication} into
+   * the {@link LocalRootSpan} of the given {@link Context}.
+   *
+   * <p>Only the attributes enabled via the {@code setEnduser*Enabled(true)} methods are captured.
+   *
+   * <p>The following attributes can be captured:
+   *
+   * <ul>
+   *   <li>{@code enduser.id} - from {@link Authentication#getName()}
+   *   <li>{@code enduser.role} - a comma-separated list from the {@link
+   *       Authentication#getAuthorities()} with the configured {@link
+   *       #getRoleGrantedAuthorityPrefix() role prefix}
+   *   <li>{@code enduser.scope} - a comma-separated list from the {@link
+   *       Authentication#getAuthorities()} with the configured {@link
+   *       #getScopeGrantedAuthorityPrefix() scope prefix}
+   * </ul>
+   *
+   * @param otelContext the context from which the {@link LocalRootSpan} in which to capture the
+   *     attributes will be retrieved
+   * @param authentication the authentication from which to determine the {@code enduser.*}
+   *     attributes.
+   */
+  public void captureEnduserAttributes(Context otelContext, Authentication authentication) {
+    if (authentication != null) {
+      Span localRootSpan = LocalRootSpan.fromContext(otelContext);
+
+      if (enduserIdEnabled && authentication.getName() != null) {
+        localRootSpan.setAttribute(SemanticAttributes.ENDUSER_ID, authentication.getName());
+      }
+
+      StringBuilder roleBuilder = null;
+      StringBuilder scopeBuilder = null;
+      if (enduserRoleEnabled || enduserScopeEnabled) {
+        for (GrantedAuthority authority : authentication.getAuthorities()) {
+          String authorityString = authority.getAuthority();
+          if (enduserRoleEnabled && authorityString.startsWith(roleGrantedAuthorityPrefix)) {
+            roleBuilder = appendSuffix(roleGrantedAuthorityPrefix, authorityString, roleBuilder);
+          } else if (enduserScopeEnabled
+              && authorityString.startsWith(scopeGrantedAuthorityPrefix)) {
+            scopeBuilder = appendSuffix(scopeGrantedAuthorityPrefix, authorityString, scopeBuilder);
+          }
+        }
+      }
+      if (roleBuilder != null) {
+        localRootSpan.setAttribute(SemanticAttributes.ENDUSER_ROLE, roleBuilder.toString());
+      }
+      if (scopeBuilder != null) {
+        localRootSpan.setAttribute(SemanticAttributes.ENDUSER_SCOPE, scopeBuilder.toString());
+      }
+    }
+  }
+
+  private static StringBuilder appendSuffix(
+      String prefix, String authorityString, StringBuilder builder) {
+    if (authorityString.length() > prefix.length()) {
+      String suffix = authorityString.substring(prefix.length());
+      if (builder == null) {
+        builder = new StringBuilder();
+        builder.append(suffix);
+      } else {
+        builder.append(",").append(suffix);
+      }
+    }
+    return builder;
+  }
+
+  public boolean isEnduserIdEnabled() {
+    return enduserIdEnabled;
+  }
+
+  public void setEnduserIdEnabled(boolean enduserIdEnabled) {
+    this.enduserIdEnabled = enduserIdEnabled;
+  }
+
+  public boolean isEnduserRoleEnabled() {
+    return enduserRoleEnabled;
+  }
+
+  public void setEnduserRoleEnabled(boolean enduserRoleEnabled) {
+    this.enduserRoleEnabled = enduserRoleEnabled;
+  }
+
+  public boolean isEnduserScopeEnabled() {
+    return enduserScopeEnabled;
+  }
+
+  public void setEnduserScopeEnabled(boolean enduserScopeEnabled) {
+    this.enduserScopeEnabled = enduserScopeEnabled;
+  }
+
+  public String getRoleGrantedAuthorityPrefix() {
+    return roleGrantedAuthorityPrefix;
+  }
+
+  public void setRoleGrantedAuthorityPrefix(String roleGrantedAuthorityPrefix) {
+    this.roleGrantedAuthorityPrefix =
+        Objects.requireNonNull(roleGrantedAuthorityPrefix, "rolePrefix must not be null");
+  }
+
+  public String getScopeGrantedAuthorityPrefix() {
+    return scopeGrantedAuthorityPrefix;
+  }
+
+  public void setScopeGrantedAuthorityPrefix(String scopeGrantedAuthorityPrefix) {
+    this.scopeGrantedAuthorityPrefix =
+        Objects.requireNonNull(scopeGrantedAuthorityPrefix, "scopePrefix must not be null");
+  }
+}

+ 46 - 0
instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesCapturingServletFilter.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet;
+
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import java.io.IOException;
+import java.util.Objects;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * A servlet {@link Filter} that captures {@code endpoint.*} semantic attributes from the {@link
+ * org.springframework.security.core.Authentication} in the current {@link
+ * org.springframework.security.core.context.SecurityContext} retrieved from {@link
+ * SecurityContextHolder}.
+ *
+ * <p>Inserted into the filter chain by {@link EnduserAttributesHttpSecurityCustomizer} after all
+ * the filters that populate the {@link org.springframework.security.core.context.SecurityContext}
+ * in the {@link org.springframework.security.core.context.SecurityContextHolder}.
+ */
+public class EnduserAttributesCapturingServletFilter implements Filter {
+
+  private final EnduserAttributesCapturer capturer;
+
+  public EnduserAttributesCapturingServletFilter(EnduserAttributesCapturer capturer) {
+    this.capturer = Objects.requireNonNull(capturer, "capturer must not be null");
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+
+    capturer.captureEnduserAttributes(
+        Context.current(), SecurityContextHolder.getContext().getAuthentication());
+
+    chain.doFilter(request, response);
+  }
+}

+ 37 - 0
instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesHttpSecurityCustomizer.java

@@ -0,0 +1,37 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import java.util.Objects;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.access.intercept.AuthorizationFilter;
+
+/**
+ * Customizes a {@link HttpSecurity} by inserting a {@link EnduserAttributesCapturingServletFilter}
+ * after all the filters that populate the {@link
+ * org.springframework.security.core.context.SecurityContext} in the {@link
+ * org.springframework.security.core.context.SecurityContextHolder}.
+ */
+public class EnduserAttributesHttpSecurityCustomizer implements Customizer<HttpSecurity> {
+
+  private final EnduserAttributesCapturer capturer;
+
+  public EnduserAttributesHttpSecurityCustomizer(EnduserAttributesCapturer capturer) {
+    this.capturer = Objects.requireNonNull(capturer, "capturer must not be null");
+  }
+
+  @Override
+  public void customize(HttpSecurity httpSecurity) {
+    /*
+     * See org.springframework.security.config.annotation.web.builders.FilterOrderRegistration
+     * for where this appears in the chain.
+     */
+    httpSecurity.addFilterBefore(
+        new EnduserAttributesCapturingServletFilter(capturer), AuthorizationFilter.class);
+  }
+}

+ 51 - 0
instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesCapturingWebFilter.java

@@ -0,0 +1,51 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux;
+
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.reactor.v3_1.ContextPropagationOperator;
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import java.util.Objects;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+/**
+ * A {@link WebFilter} that captures {@code endpoint.*} semantic attributes from the {@link
+ * org.springframework.security.core.Authentication} in the current {@link
+ * org.springframework.security.core.context.SecurityContext} retrieved from {@link
+ * ReactiveSecurityContextHolder}.
+ *
+ * <p>Inserted into the filter chain by {@link EnduserAttributesServerHttpSecurityCustomizer} after
+ * all the filters that populate the {@link
+ * org.springframework.security.core.context.SecurityContext} in the {@link
+ * org.springframework.security.core.context.ReactiveSecurityContextHolder}.
+ */
+public class EnduserAttributesCapturingWebFilter implements WebFilter {
+
+  private final EnduserAttributesCapturer capturer;
+
+  public EnduserAttributesCapturingWebFilter(EnduserAttributesCapturer capturer) {
+    this.capturer = Objects.requireNonNull(capturer, "capturer must not be null");
+  }
+
+  @Override
+  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+
+    Context threadLocalOtelContext = Context.current();
+
+    return Mono.zip(ReactiveSecurityContextHolder.getContext(), Mono.deferContextual(Mono::just))
+        .doOnNext(
+            t2 ->
+                capturer.captureEnduserAttributes(
+                    ContextPropagationOperator.getOpenTelemetryContext(
+                        reactor.util.context.Context.of(t2.getT2()), threadLocalOtelContext),
+                    t2.getT1().getAuthentication()))
+        .then(chain.filter(exchange));
+  }
+}

+ 34 - 0
instrumentation/spring/spring-security-config-6.0/library/src/main/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesServerHttpSecurityCustomizer.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import java.util.Objects;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+
+/**
+ * Customizes a {@link ServerHttpSecurity} by inserting a {@link
+ * EnduserAttributesCapturingWebFilter} after all the filters that populate the {@link
+ * org.springframework.security.core.context.SecurityContext} in the {@link
+ * org.springframework.security.core.context.ReactiveSecurityContextHolder}.
+ */
+public class EnduserAttributesServerHttpSecurityCustomizer
+    implements Customizer<ServerHttpSecurity> {
+
+  private final EnduserAttributesCapturer capturer;
+
+  public EnduserAttributesServerHttpSecurityCustomizer(EnduserAttributesCapturer capturer) {
+    this.capturer = Objects.requireNonNull(capturer, "capturer must not be null");
+  }
+
+  @Override
+  public void customize(ServerHttpSecurity serverHttpSecurity) {
+    serverHttpSecurity.addFilterBefore(
+        new EnduserAttributesCapturingWebFilter(capturer), SecurityWebFiltersOrder.LOGOUT);
+  }
+}

+ 217 - 0
instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/EnduserAttributesCapturerTest.java

@@ -0,0 +1,217 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
+import io.opentelemetry.instrumentation.testing.junit.LibraryInstrumentationExtension;
+import io.opentelemetry.sdk.testing.assertj.SpanDataAssert;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.semconv.SemanticAttributes;
+import java.util.Arrays;
+import java.util.function.Consumer;
+import org.assertj.core.api.Condition;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
+
+public class EnduserAttributesCapturerTest {
+
+  @RegisterExtension InstrumentationExtension testing = LibraryInstrumentationExtension.create();
+
+  @Test
+  void nothingEnabled() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("ROLE_role1"),
+                new SimpleGrantedAuthority("ROLE_role2"),
+                new SimpleGrantedAuthority("SCOPE_scope1"),
+                new SimpleGrantedAuthority("SCOPE_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.doesNotHave(attribute(SemanticAttributes.ENDUSER_ID))
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_ROLE))
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_SCOPE)));
+  }
+
+  @Test
+  void allEnabledButNoRoles() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+    capturer.setEnduserRoleEnabled(true);
+    capturer.setEnduserScopeEnabled(true);
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("SCOPE_scope1"),
+                new SimpleGrantedAuthority("SCOPE_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_ROLE))
+                .hasAttribute(SemanticAttributes.ENDUSER_SCOPE, "scope1,scope2"));
+  }
+
+  @Test
+  void allEnabledButNoScopes() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+    capturer.setEnduserRoleEnabled(true);
+    capturer.setEnduserScopeEnabled(true);
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("ROLE_role1"),
+                new SimpleGrantedAuthority("ROLE_role2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                .hasAttribute(SemanticAttributes.ENDUSER_ROLE, "role1,role2")
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_SCOPE)));
+  }
+
+  @Test
+  void onlyEnduserIdEnabled() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("ROLE_role1"),
+                new SimpleGrantedAuthority("ROLE_role2"),
+                new SimpleGrantedAuthority("SCOPE_scope1"),
+                new SimpleGrantedAuthority("SCOPE_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_ROLE))
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_SCOPE)));
+  }
+
+  @Test
+  void onlyEnduserRoleEnabled() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserRoleEnabled(true);
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("ROLE_role1"),
+                new SimpleGrantedAuthority("ROLE_role2"),
+                new SimpleGrantedAuthority("SCOPE_scope1"),
+                new SimpleGrantedAuthority("SCOPE_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.doesNotHave(attribute(SemanticAttributes.ENDUSER_ID))
+                .hasAttribute(SemanticAttributes.ENDUSER_ROLE, "role1,role2")
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_SCOPE)));
+  }
+
+  @Test
+  void onlyEnduserScopeEnabled() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserScopeEnabled(true);
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("ROLE_role1"),
+                new SimpleGrantedAuthority("ROLE_role2"),
+                new SimpleGrantedAuthority("SCOPE_scope1"),
+                new SimpleGrantedAuthority("SCOPE_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.doesNotHave(attribute(SemanticAttributes.ENDUSER_ID))
+                .doesNotHave(attribute(SemanticAttributes.ENDUSER_ROLE))
+                .hasAttribute(SemanticAttributes.ENDUSER_SCOPE, "scope1,scope2"));
+  }
+
+  @Test
+  void allEnabledAndAlternatePrefix() {
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+    capturer.setEnduserRoleEnabled(true);
+    capturer.setEnduserScopeEnabled(true);
+    capturer.setRoleGrantedAuthorityPrefix("role_");
+    capturer.setScopeGrantedAuthorityPrefix("scope_");
+
+    Authentication authentication =
+        new PreAuthenticatedAuthenticationToken(
+            "principal",
+            null,
+            Arrays.asList(
+                new SimpleGrantedAuthority("role_role1"),
+                new SimpleGrantedAuthority("role_role2"),
+                new SimpleGrantedAuthority("scope_scope1"),
+                new SimpleGrantedAuthority("scope_scope2")));
+
+    test(
+        capturer,
+        authentication,
+        span ->
+            span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                .hasAttribute(SemanticAttributes.ENDUSER_ROLE, "role1,role2")
+                .hasAttribute(SemanticAttributes.ENDUSER_SCOPE, "scope1,scope2"));
+  }
+
+  void test(
+      EnduserAttributesCapturer capturer,
+      Authentication authentication,
+      Consumer<SpanDataAssert> assertions) {
+    testing.runWithHttpServerSpan(
+        () -> {
+          Context otelContext = Context.current();
+          capturer.captureEnduserAttributes(otelContext, authentication);
+        });
+
+    testing.waitAndAssertTraces(trace -> trace.hasSpansSatisfyingExactly(assertions));
+  }
+
+  private static Condition<SpanData> attribute(AttributeKey<?> attributeKey) {
+    return new Condition<>(
+        spanData -> spanData.getAttributes().get(attributeKey) != null,
+        "attribute " + attributeKey);
+  }
+}

+ 82 - 0
instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesCapturingServletFilterTest.java

@@ -0,0 +1,82 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
+import io.opentelemetry.instrumentation.testing.junit.LibraryInstrumentationExtension;
+import io.opentelemetry.semconv.SemanticAttributes;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
+
+class EnduserAttributesCapturingServletFilterTest {
+
+  @RegisterExtension InstrumentationExtension testing = LibraryInstrumentationExtension.create();
+
+  /**
+   * Tests to ensure enduser attributes are captured.
+   *
+   * <p>This just tests one scenario of {@link EnduserAttributesCapturer} to ensure that it is
+   * invoked properly by the filter. {@link
+   * io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturerTest}
+   * tests many other scenarios.
+   */
+  @Test
+  void test() throws Exception {
+
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+    capturer.setEnduserRoleEnabled(true);
+    capturer.setEnduserScopeEnabled(true);
+    EnduserAttributesCapturingServletFilter filter =
+        new EnduserAttributesCapturingServletFilter(capturer);
+
+    testing.runWithHttpServerSpan(
+        () -> {
+          ServletRequest request = new MockHttpServletRequest();
+          ServletResponse response = new MockHttpServletResponse();
+          FilterChain filterChain = new MockFilterChain();
+
+          SecurityContext previousSecurityContext = SecurityContextHolder.getContext();
+          try {
+            SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+            securityContext.setAuthentication(
+                new PreAuthenticatedAuthenticationToken(
+                    "principal",
+                    null,
+                    Arrays.asList(
+                        new SimpleGrantedAuthority("ROLE_role1"),
+                        new SimpleGrantedAuthority("ROLE_role2"),
+                        new SimpleGrantedAuthority("SCOPE_scope1"),
+                        new SimpleGrantedAuthority("SCOPE_scope2"))));
+            SecurityContextHolder.setContext(securityContext);
+
+            filter.doFilter(request, response, filterChain);
+          } finally {
+            SecurityContextHolder.setContext(previousSecurityContext);
+          }
+        });
+
+    testing.waitAndAssertTraces(
+        trace ->
+            trace.hasSpansSatisfyingExactly(
+                span ->
+                    span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                        .hasAttribute(SemanticAttributes.ENDUSER_ROLE, "role1,role2")
+                        .hasAttribute(SemanticAttributes.ENDUSER_SCOPE, "scope1,scope2")));
+  }
+}

+ 61 - 0
instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/servlet/EnduserAttributesHttpSecurityCustomizerTest.java

@@ -0,0 +1,61 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.servlet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.ObjectPostProcessor;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.DefaultSecurityFilterChain;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SpringExtension.class)
+class EnduserAttributesHttpSecurityCustomizerTest {
+
+  @Configuration
+  static class TestConfiguration {}
+
+  @Mock ObjectPostProcessor<Object> objectPostProcessor;
+
+  /**
+   * Ensures that the {@link EnduserAttributesHttpSecurityCustomizer} registers a {@link
+   * EnduserAttributesCapturingServletFilter} in the filter chain.
+   *
+   * <p>Usage of the filter is covered in other unit tests.
+   */
+  @Test
+  void ensureFilterRegistered(@Autowired ApplicationContext applicationContext) throws Exception {
+
+    AuthenticationManagerBuilder authenticationBuilder =
+        new AuthenticationManagerBuilder(objectPostProcessor);
+    HttpSecurity httpSecurity =
+        new HttpSecurity(
+            objectPostProcessor,
+            authenticationBuilder,
+            Collections.singletonMap(ApplicationContext.class, applicationContext));
+
+    EnduserAttributesHttpSecurityCustomizer customizer =
+        new EnduserAttributesHttpSecurityCustomizer(new EnduserAttributesCapturer());
+    customizer.customize(httpSecurity);
+
+    DefaultSecurityFilterChain filterChain = httpSecurity.build();
+
+    assertThat(filterChain.getFilters())
+        .filteredOn(EnduserAttributesCapturingServletFilter.class::isInstance)
+        .hasSize(1);
+  }
+}

+ 80 - 0
instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesCapturingWebFilterTest.java

@@ -0,0 +1,80 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux;
+
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.reactor.v3_1.ContextPropagationOperator;
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
+import io.opentelemetry.instrumentation.testing.junit.LibraryInstrumentationExtension;
+import io.opentelemetry.semconv.SemanticAttributes;
+import java.util.Arrays;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
+import org.springframework.web.server.handler.DefaultWebFilterChain;
+import reactor.core.publisher.Mono;
+
+class EnduserAttributesCapturingWebFilterTest {
+
+  @RegisterExtension InstrumentationExtension testing = LibraryInstrumentationExtension.create();
+
+  /**
+   * Tests to ensure enduser attributes are captured.
+   *
+   * <p>This just tests one scenario of {@link EnduserAttributesCapturer} to ensure that it is
+   * invoked properly by the filter. {@link
+   * io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturerTest}
+   * tests many other scenarios.
+   */
+  @Test
+  void test() {
+
+    EnduserAttributesCapturer capturer = new EnduserAttributesCapturer();
+    capturer.setEnduserIdEnabled(true);
+    capturer.setEnduserRoleEnabled(true);
+    capturer.setEnduserScopeEnabled(true);
+    EnduserAttributesCapturingWebFilter filter = new EnduserAttributesCapturingWebFilter(capturer);
+
+    testing.runWithHttpServerSpan(
+        () -> {
+          MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
+          MockServerWebExchange exchange = MockServerWebExchange.from(request);
+          DefaultWebFilterChain filterChain =
+              new DefaultWebFilterChain(exch -> Mono.empty(), Collections.emptyList());
+          Context otelContext = Context.current();
+          filter
+              .filter(exchange, filterChain)
+              .contextWrite(
+                  ReactiveSecurityContextHolder.withAuthentication(
+                      new PreAuthenticatedAuthenticationToken(
+                          "principal",
+                          null,
+                          Arrays.asList(
+                              new SimpleGrantedAuthority("ROLE_role1"),
+                              new SimpleGrantedAuthority("ROLE_role2"),
+                              new SimpleGrantedAuthority("SCOPE_scope1"),
+                              new SimpleGrantedAuthority("SCOPE_scope2")))))
+              .contextWrite(
+                  context ->
+                      ContextPropagationOperator.storeOpenTelemetryContext(context, otelContext))
+              .block();
+        });
+
+    testing.waitAndAssertTraces(
+        trace ->
+            trace.hasSpansSatisfyingExactly(
+                span ->
+                    span.hasAttribute(SemanticAttributes.ENDUSER_ID, "principal")
+                        .hasAttribute(SemanticAttributes.ENDUSER_ROLE, "role1,role2")
+                        .hasAttribute(SemanticAttributes.ENDUSER_SCOPE, "scope1,scope2")));
+  }
+}

+ 39 - 0
instrumentation/spring/spring-security-config-6.0/library/src/test/java/io/opentelemetry/instrumentation/spring/security/config/v6_0/webflux/EnduserAttributesServerHttpSecurityCustomizerTest.java

@@ -0,0 +1,39 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.instrumentation.spring.security.config.v6_0.webflux;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.opentelemetry.instrumentation.spring.security.config.v6_0.EnduserAttributesCapturer;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+
+class EnduserAttributesServerHttpSecurityCustomizerTest {
+
+  /**
+   * Ensures that the {@link EnduserAttributesServerHttpSecurityCustomizer} registers a {@link
+   * EnduserAttributesCapturingWebFilter} in the filter chain.
+   *
+   * <p>Usage of the filter is covered in other unit tests.
+   */
+  @Test
+  void ensureFilterRegistered() {
+
+    ServerHttpSecurity serverHttpSecurity = ServerHttpSecurity.http();
+
+    EnduserAttributesServerHttpSecurityCustomizer customizer =
+        new EnduserAttributesServerHttpSecurityCustomizer(new EnduserAttributesCapturer());
+
+    customizer.customize(serverHttpSecurity);
+
+    SecurityWebFilterChain securityWebFilterChain = serverHttpSecurity.build();
+
+    assertThat(securityWebFilterChain.getWebFilters().collectList().block())
+        .filteredOn(EnduserAttributesCapturingWebFilter.class::isInstance)
+        .hasSize(1);
+  }
+}

+ 6 - 6
javaagent-extension-api/src/main/java/io/opentelemetry/javaagent/bootstrap/internal/CommonConfig.java

@@ -32,10 +32,10 @@ public final class CommonConfig {
   private final List<String> serverRequestHeaders;
   private final List<String> serverResponseHeaders;
   private final Set<String> knownHttpRequestMethods;
+  private final EnduserConfig enduserConfig;
   private final boolean statementSanitizationEnabled;
   private final boolean emitExperimentalHttpClientTelemetry;
   private final boolean emitExperimentalHttpServerTelemetry;
-  private final boolean captureEnduser;
 
   CommonConfig(InstrumentationConfig config) {
     peerServiceResolver =
@@ -82,7 +82,7 @@ public final class CommonConfig {
             "otel.instrumentation.http.server.emit-experimental-metrics",
             "otel.instrumentation.http.server.emit-experimental-telemetry",
             false);
-    captureEnduser = config.getBoolean("otel.instrumentation.common.enduser.id.enabled", false);
+    enduserConfig = new EnduserConfig(config);
   }
 
   public PeerServiceResolver getPeerServiceResolver() {
@@ -109,6 +109,10 @@ public final class CommonConfig {
     return knownHttpRequestMethods;
   }
 
+  public EnduserConfig getEnduserConfig() {
+    return enduserConfig;
+  }
+
   public boolean isStatementSanitizationEnabled() {
     return statementSanitizationEnabled;
   }
@@ -120,8 +124,4 @@ public final class CommonConfig {
   public boolean shouldEmitExperimentalHttpServerTelemetry() {
     return emitExperimentalHttpServerTelemetry;
   }
-
-  public boolean shouldCaptureEnduser() {
-    return captureEnduser;
-  }
 }

+ 97 - 0
javaagent-extension-api/src/main/java/io/opentelemetry/javaagent/bootstrap/internal/EnduserConfig.java

@@ -0,0 +1,97 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.bootstrap.internal;
+
+import java.util.Objects;
+
+/**
+ * Configuration that controls capturing the {@code enduser.*} semantic attributes.
+ *
+ * <p>The {@code enduser.*} semantic attributes are not captured by default, due to this text in the
+ * specification:
+ *
+ * <blockquote>
+ *
+ * Given the sensitive nature of this information, SDKs and exporters SHOULD drop these attributes
+ * by default and then provide a configuration parameter to turn on retention for use cases where
+ * the information is required and would not violate any policies or regulations.
+ *
+ * </blockquote>
+ *
+ * <p>Capturing of the {@code enduser.*} semantic attributes can be individually enabled by
+ * configured the following properties:
+ *
+ * <pre>
+ * otel.instrumentation.common.enduser.id.enabled=true
+ * otel.instrumentation.common.enduser.role.enabled=true
+ * otel.instrumentation.common.enduser.scope.enabled=true
+ * </pre>
+ *
+ * <p>This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public class EnduserConfig {
+
+  private final boolean idEnabled;
+  private final boolean roleEnabled;
+  private final boolean scopeEnabled;
+
+  EnduserConfig(InstrumentationConfig instrumentationConfig) {
+    Objects.requireNonNull(instrumentationConfig, "instrumentationConfig must not be null");
+
+    /*
+     * Capturing enduser.* attributes is disabled by default, because of this requirement in the specification:
+     *
+     * Given the sensitive nature of this information, SDKs and exporters SHOULD drop these attributes by default and then provide a configuration parameter to turn on retention for use cases where the information is required and would not violate any policies or regulations.
+     *
+     * https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/attributes.md#general-identity-attributes
+     */
+    this.idEnabled =
+        instrumentationConfig.getBoolean("otel.instrumentation.common.enduser.id.enabled", false);
+    this.roleEnabled =
+        instrumentationConfig.getBoolean("otel.instrumentation.common.enduser.role.enabled", false);
+    this.scopeEnabled =
+        instrumentationConfig.getBoolean(
+            "otel.instrumentation.common.enduser.scope.enabled", false);
+  }
+
+  /**
+   * Returns true if capturing of any {@code enduser.*} semantic attribute is enabled.
+   *
+   * <p>This flag can be used by capturing instrumentations to bypass all {@code enduser.*}
+   * attribute capturing.
+   */
+  public boolean isAnyEnabled() {
+    return this.idEnabled || this.roleEnabled || this.scopeEnabled;
+  }
+
+  /**
+   * Returns true if capturing the {@code enduser.id} semantic attribute is enabled.
+   *
+   * @return true if capturing the {@code enduser.id} semantic attribute is enabled.
+   */
+  public boolean isIdEnabled() {
+    return this.idEnabled;
+  }
+
+  /**
+   * Returns true if capturing the {@code enduser.role} semantic attribute is enabled.
+   *
+   * @return true if capturing the {@code enduser.role} semantic attribute is enabled.
+   */
+  public boolean isRoleEnabled() {
+    return this.roleEnabled;
+  }
+
+  /**
+   * Returns true if capturing the {@code enduser.scope} semantic attribute is enabled.
+   *
+   * @return true if capturing the {@code enduser.scope} semantic attribute is enabled.
+   */
+  public boolean isScopeEnabled() {
+    return this.scopeEnabled;
+  }
+}

+ 2 - 0
settings.gradle.kts

@@ -513,6 +513,8 @@ include(":instrumentation:spring:spring-rabbit-1.0:javaagent")
 include(":instrumentation:spring:spring-rmi-4.0:javaagent")
 include(":instrumentation:spring:spring-scheduling-3.1:bootstrap")
 include(":instrumentation:spring:spring-scheduling-3.1:javaagent")
+include(":instrumentation:spring:spring-security-config-6.0:javaagent")
+include(":instrumentation:spring:spring-security-config-6.0:library")
 include(":instrumentation:spring:spring-web:spring-web-3.1:javaagent")
 include(":instrumentation:spring:spring-web:spring-web-3.1:library")
 include(":instrumentation:spring:spring-web:spring-web-3.1:testing")