diff --git a/gcp-auth-extension/build.gradle.kts b/gcp-auth-extension/build.gradle.kts index 112113e66..7e1460f67 100644 --- a/gcp-auth-extension/build.gradle.kts +++ b/gcp-auth-extension/build.gradle.kts @@ -55,6 +55,9 @@ dependencies { tasks { test { useJUnitPlatform() + // Unset relevant environment variables to provide a clean state for the tests + environment("GOOGLE_CLOUD_PROJECT", "") + environment("GOOGLE_CLOUD_QUOTA_PROJECT", "") // exclude integration test exclude("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class") } @@ -103,7 +106,7 @@ tasks.register("copyAgent") { }) } -tasks.register("IntegrationTest") { +tasks.register("IntegrationTestUserCreds") { dependsOn(tasks.shadowJar) dependsOn(tasks.named("copyAgent")) @@ -111,7 +114,7 @@ tasks.register("IntegrationTest") { // include only the integration test file include("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class") - val fakeCredsFilePath = project.file("src/test/resources/fakecreds.json").absolutePath + val fakeCredsFilePath = project.file("src/test/resources/fake_user_creds.json").absolutePath environment("GOOGLE_CLOUD_QUOTA_PROJECT", "quota-project-id") environment("GOOGLE_APPLICATION_CREDENTIALS", fakeCredsFilePath) @@ -127,6 +130,7 @@ tasks.register("IntegrationTest") { "-Dotel.metrics.exporter=none", "-Dotel.logs.exporter=none", "-Dotel.exporter.otlp.protocol=http/protobuf", - "-Dmockserver.logLevel=off" + "-Dotel.javaagent.debug=false", + "-Dmockserver.logLevel=trace" ) } diff --git a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/ConfigurableOption.java b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/ConfigurableOption.java index 1bf90e48f..7928f9ab4 100644 --- a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/ConfigurableOption.java +++ b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/ConfigurableOption.java @@ -7,6 +7,7 @@ import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; import java.util.Locale; +import java.util.Optional; import java.util.function.Supplier; /** @@ -101,4 +102,20 @@ String getConfiguredValueWithFallback(Supplier fallback) { return fallback.get(); } } + + /** + * Retrieves the value for this option, prioritizing environment variables before system + * properties. If neither an environment variable nor a system property is set for this option, + * then an empty {@link Optional} is returned. + * + * @return The configured value for the option, if set, obtained from the environment variable, + * system property, or empty {@link Optional}, in that order of precedence. + */ + Optional getConfiguredValueAsOptional() { + try { + return Optional.of(this.getConfiguredValue()); + } catch (ConfigurationException e) { + return Optional.empty(); + } + } } diff --git a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java index 70e9bdd3b..053aeef7d 100644 --- a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java +++ b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java @@ -20,8 +20,11 @@ import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.export.SpanExporter; import java.io.IOException; -import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; /** * An AutoConfigurationCustomizerProvider for Google Cloud Platform (GCP) OpenTelemetry (OTLP) @@ -40,7 +43,7 @@ public class GcpAuthAutoConfigurationCustomizerProvider implements AutoConfigurationCustomizerProvider { - static final String QUOTA_USER_PROJECT_HEADER = "X-Goog-User-Project"; + static final String QUOTA_USER_PROJECT_HEADER = "x-goog-user-project"; static final String GCP_USER_PROJECT_ID_KEY = "gcp.project_id"; /** @@ -95,20 +98,34 @@ private static SpanExporter addAuthorizationHeaders( } private static Map getRequiredHeaderMap(GoogleCredentials credentials) { - Map gcpHeaders = new HashMap<>(); + Map> gcpHeaders; try { - credentials.refreshIfExpired(); + // this also refreshes the credentials, if required + gcpHeaders = credentials.getRequestMetadata(); } catch (IOException e) { throw new GoogleAuthException(Reason.FAILED_ADC_REFRESH, e); } - gcpHeaders.put("Authorization", "Bearer " + credentials.getAccessToken().getTokenValue()); - String configuredQuotaProjectId = - ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getConfiguredValueWithFallback( - credentials::getQuotaProjectId); - if (configuredQuotaProjectId != null && !configuredQuotaProjectId.isEmpty()) { - gcpHeaders.put(QUOTA_USER_PROJECT_HEADER, configuredQuotaProjectId); + // flatten list + Map flattenedHeaders = + gcpHeaders.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> + entry.getValue().stream() + .filter(Objects::nonNull) // Filter nulls + .filter(s -> !s.isEmpty()) // Filter empty strings + .collect(Collectors.joining(",")))); + // Add quota user project header if not detected by the auth library and user provided it via + // system properties. + if (!flattenedHeaders.containsKey(QUOTA_USER_PROJECT_HEADER)) { + Optional maybeConfiguredQuotaProjectId = + ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getConfiguredValueAsOptional(); + maybeConfiguredQuotaProjectId.ifPresent( + configuredQuotaProjectId -> + flattenedHeaders.put(QUOTA_USER_PROJECT_HEADER, configuredQuotaProjectId)); } - return gcpHeaders; + return flattenedHeaders; } // Updates the current resource with the attributes required for ingesting OTLP data on GCP. diff --git a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java index 39f626e61..5cbee0890 100644 --- a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java +++ b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java @@ -35,6 +35,7 @@ import io.opentelemetry.sdk.common.CompletableResultCode; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.AbstractMap.SimpleEntry; @@ -214,26 +215,33 @@ public void testCustomizerFailWithMissingResourceProject() { @ParameterizedTest @MethodSource("provideQuotaBehaviorTestCases") @SuppressWarnings("CannotMockMethod") - public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) { + public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) throws IOException { // Set resource project system property System.setProperty( ConfigurableOption.GOOGLE_CLOUD_PROJECT.getSystemProperty(), DUMMY_GCP_RESOURCE_PROJECT_ID); - // Configure mock credentials to return fake access token - Mockito.when(mockedGoogleCredentials.getAccessToken()) - .thenReturn(new AccessToken("fake", Date.from(Instant.now()))); - - // To prevent unncecessary stubbings, mock getQuotaProjectId only when necessary - if (testCase.getUserSpecifiedQuotaProjectId() == null - || testCase.getUserSpecifiedQuotaProjectId().isEmpty()) { - String quotaProjectFromCredential = - testCase.getIsQuotaProjectPresentInCredentials() ? DUMMY_GCP_QUOTA_PROJECT_ID : null; - Mockito.when(mockedGoogleCredentials.getQuotaProjectId()) - .thenReturn(quotaProjectFromCredential); + + // Prepare request metadata + AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now())); + ImmutableMap> mockedRequestMetadata; + if (testCase.getIsQuotaProjectPresentInMetadata()) { + mockedRequestMetadata = + ImmutableMap.of( + "Authorization", + Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()), + QUOTA_USER_PROJECT_HEADER, + Collections.singletonList(DUMMY_GCP_QUOTA_PROJECT_ID)); + } else { + mockedRequestMetadata = + ImmutableMap.of( + "Authorization", + Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue())); } + // mock credentials to return the prepared request metadata + Mockito.when(mockedGoogleCredentials.getRequestMetadata()).thenReturn(mockedRequestMetadata); // configure environment according to test case String quotaProjectId = testCase.getUserSpecifiedQuotaProjectId(); // maybe empty string - if (testCase.getUserSpecifiedQuotaProjectId() != null) { + if (quotaProjectId != null) { // user specified a quota project id System.setProperty( ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getSystemProperty(), quotaProjectId); @@ -288,58 +296,62 @@ public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) { * indicates the expectation that the QUOTA_USER_PROJECT_HEADER should not be present in the * export headers. * - *

{@code true} for {@link QuotaProjectIdTestBehavior#getIsQuotaProjectPresentInCredentials()} + *

{@code true} for {@link QuotaProjectIdTestBehavior#getIsQuotaProjectPresentInMetadata()} * indicates that the mocked credentials are configured to provide DUMMY_GCP_QUOTA_PROJECT_ID as * the quota project ID. */ private static Stream provideQuotaBehaviorTestCases() { return Stream.of( + // If quota project present in metadata, it will be used Arguments.of( QuotaProjectIdTestBehavior.builder() .setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID) - .setIsQuotaProjectPresentInCredentials(true) + .setIsQuotaProjectPresentInMetadata(true) .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) .build()), Arguments.of( QuotaProjectIdTestBehavior.builder() - .setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID) - .setIsQuotaProjectPresentInCredentials(false) + .setUserSpecifiedQuotaProjectId("my-custom-quota-project-id") + .setIsQuotaProjectPresentInMetadata(true) .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) .build()), + // If quota project not present in request metadata, then user specified project is used Arguments.of( QuotaProjectIdTestBehavior.builder() - .setUserSpecifiedQuotaProjectId("my-custom-quota-project-id") - .setIsQuotaProjectPresentInCredentials(true) - .setExpectedQuotaProjectInHeader("my-custom-quota-project-id") + .setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID) + .setIsQuotaProjectPresentInMetadata(false) + .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) .build()), Arguments.of( QuotaProjectIdTestBehavior.builder() .setUserSpecifiedQuotaProjectId("my-custom-quota-project-id") - .setIsQuotaProjectPresentInCredentials(false) + .setIsQuotaProjectPresentInMetadata(false) .setExpectedQuotaProjectInHeader("my-custom-quota-project-id") .build()), + // Testing for special edge case inputs + // user-specified quota project is empty Arguments.of( QuotaProjectIdTestBehavior.builder() .setUserSpecifiedQuotaProjectId("") // user explicitly specifies empty - .setIsQuotaProjectPresentInCredentials(true) + .setIsQuotaProjectPresentInMetadata(true) .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) .build()), Arguments.of( QuotaProjectIdTestBehavior.builder() - .setUserSpecifiedQuotaProjectId(null) // user omits specifying quota project - .setIsQuotaProjectPresentInCredentials(true) - .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) + .setUserSpecifiedQuotaProjectId("") + .setIsQuotaProjectPresentInMetadata(false) + .setExpectedQuotaProjectInHeader(null) .build()), Arguments.of( QuotaProjectIdTestBehavior.builder() - .setUserSpecifiedQuotaProjectId("") - .setIsQuotaProjectPresentInCredentials(false) - .setExpectedQuotaProjectInHeader(null) + .setUserSpecifiedQuotaProjectId(null) // user omits specifying quota project + .setIsQuotaProjectPresentInMetadata(true) + .setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID) .build()), Arguments.of( QuotaProjectIdTestBehavior.builder() .setUserSpecifiedQuotaProjectId(null) - .setIsQuotaProjectPresentInCredentials(false) + .setIsQuotaProjectPresentInMetadata(false) .setExpectedQuotaProjectInHeader(null) .build())); } @@ -367,7 +379,7 @@ abstract static class QuotaProjectIdTestBehavior { @Nullable abstract String getUserSpecifiedQuotaProjectId(); - abstract boolean getIsQuotaProjectPresentInCredentials(); + abstract boolean getIsQuotaProjectPresentInMetadata(); // If expected quota project in header is null, the header entry should not be present in export @Nullable @@ -382,9 +394,15 @@ static Builder builder() { abstract static class Builder { abstract Builder setUserSpecifiedQuotaProjectId(String quotaProjectId); - abstract Builder setIsQuotaProjectPresentInCredentials( - boolean quotaProjectPresentInCredentials); + abstract Builder setIsQuotaProjectPresentInMetadata(boolean quotaProjectPresentInMetadata); + /** + * Sets the expected quota project header value for the test case. A null value is allowed, + * and it indicates that the header should not be present in the export request. + * + * @param expectedQuotaProjectInHeader the expected header value to match in the export + * headers. + */ abstract Builder setExpectedQuotaProjectInHeader(String expectedQuotaProjectInHeader); abstract QuotaProjectIdTestBehavior build(); @@ -393,10 +411,18 @@ abstract Builder setIsQuotaProjectPresentInCredentials( @SuppressWarnings("CannotMockMethod") private void prepareMockBehaviorForGoogleCredentials() { - Mockito.when(mockedGoogleCredentials.getQuotaProjectId()) - .thenReturn(DUMMY_GCP_QUOTA_PROJECT_ID); - Mockito.when(mockedGoogleCredentials.getAccessToken()) - .thenReturn(new AccessToken("fake", Date.from(Instant.now()))); + AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now())); + try { + Mockito.when(mockedGoogleCredentials.getRequestMetadata()) + .thenReturn( + ImmutableMap.of( + "Authorization", + Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()), + QUOTA_USER_PROJECT_HEADER, + Collections.singletonList(DUMMY_GCP_QUOTA_PROJECT_ID))); + } catch (IOException e) { + throw new RuntimeException(e); + } } private OpenTelemetrySdk buildOpenTelemetrySdkWithExporter(SpanExporter spanExporter) { diff --git a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.java b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.java index 421d4fcec..e04baed93 100644 --- a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.java +++ b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.java @@ -89,10 +89,9 @@ public static void setup() throws NoSuchAlgorithmException, KeyManagementExcepti // Set up mock OTLP backend server to which traces will be exported backendServer = ClientAndServer.startClientAndServer(EXPORTER_ENDPOINT_PORT); backendServer.when(request()).respond(response().withStatusCode(200)); - - // Set up the mock gcp metadata server to provide fake credentials String accessTokenResponse = "{\"access_token\": \"fake.access_token\",\"expires_in\": 3600, \"token_type\": \"Bearer\"}"; + mockGcpOAuth2Server = ClientAndServer.startClientAndServer(MOCK_GCP_OAUTH2_PORT); MockServerClient mockServerClient = diff --git a/gcp-auth-extension/src/test/resources/fake_user_creds.json b/gcp-auth-extension/src/test/resources/fake_user_creds.json new file mode 100644 index 000000000..fd798897f --- /dev/null +++ b/gcp-auth-extension/src/test/resources/fake_user_creds.json @@ -0,0 +1,7 @@ +{ + "client_id": "....apps.googleusercontent.com", + "client_secret": "...", + "refresh_token": "1//...", + "quota_project_id": "your-configured-quota-project", + "type": "authorized_user" +} diff --git a/gcp-auth-extension/src/test/resources/fakecreds.json b/gcp-auth-extension/src/test/resources/fakecreds.json deleted file mode 100644 index 1000f70db..000000000 --- a/gcp-auth-extension/src/test/resources/fakecreds.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "service_account", - "project_id": "quota-project-id", - "private_key_id": "aljmafmlamlmmasma", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12ikv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/GrCtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrPSXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n-----END PRIVATE KEY-----\n", - "client_email": "sample@appspot.gserviceaccount.com", - "client_id": "100000000000000000221", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "", - "client_x509_cert_url": "", - "universe_domain": "googleapis.com" -}