Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.acceptance;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.opentelemetry.sdk.logs.data.LogRecordData;
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.zowe.apiml.auth.AuthenticationScheme;
import org.zowe.apiml.gateway.MockService;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.awaitility.Awaitility.await;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;

class OpenTelemetryAnonymousBypassTest {
Comment thread
balhar-jakub marked this conversation as resolved.

@Nested
@AcceptanceTest
@ActiveProfiles({"OpenTelemetryTest"})
@TestPropertySource(
properties = {
"otel.sdk.disabled=false",
"otel.metrics.exporter=none",
"otel.traces.exporter=none",
"otel.logs.exporter=none"
}
)
@DirtiesContext

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to DirtyContext? Is there some alternative way to test, as DirtiesContext is actually quite expensive operation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the same approach that is taken for testing the other OTel pieces?

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WhenAnonymousBypassRequest extends AcceptanceTestWithMockServices {

@Autowired
private LogRecordExporter logExporter;

@LocalServerPort
private int port;

private MockService mockServiceBypass;

@BeforeAll
void init() {
mockServiceBypass = mockService("testservicebp")
.scope(MockService.Scope.CLASS)
.authenticationScheme(AuthenticationScheme.BYPASS)
.addEndpoint("/testservicebp/200")
.responseCode(200)
.and()
.addEndpoint("/testservicebp/500")
.responseCode(500)
.and().start();
}

@BeforeEach
void setUp() {
assertTrue(logExporter instanceof InMemoryLogRecordExporter,
"Expected InMemoryLogRecordExporter, got " + logExporter.getClass().getName());
((InMemoryLogRecordExporter) logExporter).reset();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to type already on the field to InMemoryLogRecordExporter?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And thus remove this whole setUp method?

}

private List<LogRecordData> assertLogsExported() {
List<LogRecordData> logs = new ArrayList<>();
await("Log export")
.atMost(Duration.ofSeconds(10))
.until(() -> {
var exporter = (InMemoryLogRecordExporter) logExporter;
var l = exporter.getFinishedLogRecordItems();
if (l.size() > 0) {
logs.addAll(l);
}
exporter.reset();
return l.size() > 0;
});
return logs;
}

private LogRecordData assertOneLogRecordExported(String expectedUrl) {
var logs = assertLogsExported();

var logRecord = logs.stream()
.filter(log -> log.getBodyValue().asString().contains(expectedUrl))
.findFirst()
.orElseThrow(() -> new AssertionError(
"Expected log record with URL " + expectedUrl + " not found in logs: "
+ logs.stream().map(LogRecordData::getBodyValue).map(String::valueOf).collect(Collectors.joining(", "))));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you getBodyValue, then map it, collect it and then again later on do something very similar again or even build JSON out of it?


assertEquals("INFO", logRecord.getSeverityText(),
"Expected INFO log level, was " + logRecord.getSeverityText());

var logBody = logRecord.getBodyValue().asString();
assertTrue(StringUtils.isNotBlank(logBody));

return logRecord;
}

private Object getAttribute(String logBody, String attributeName) {
var objectMapper = new ObjectMapper();
try {
return objectMapper.readValue(logBody, Map.class).get(attributeName);
} catch (JsonProcessingException e) {
fail("Invalid JSON", e);
return null;
}
}

@Test
void thenLogWithAnonymousUserIdAndAuthOk() {
given()
.get(basePath + "/testservicebp/api/v1/200")
.then()
.statusCode(200);

var logRecord = assertOneLogRecordExported("/testservicebp/api/v1/200");
@SuppressWarnings("null")
var logBody = logRecord.getBodyValue().asString();

// Full attribute set verification
assertEquals("GET", getAttribute(logBody, "http.request.method"),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of the lines below is always reconverting the logBody within getAttribute, is this actually a good implementation?

"http.request.method should be GET");
assertEquals("https", getAttribute(logBody, "url.scheme"),
"url.scheme should be https");
assertEquals("/testservicebp/api/v1/200", getAttribute(logBody, "url.path"),
"url.path should match request path");
assertEquals("testservicebp", getAttribute(logBody, "service.id"),
"service.id should be testservicebp");
assertEquals("localhost:testservicebp:" + mockServiceBypass.getPort(),
getAttribute(logBody, "service.instance.id"),
"service.instance.id should match mock service");
assertEquals("bypass", getAttribute(logBody, "auth.service.auth.method"),
"auth.service.auth.method should be bypass");
assertEquals("200", getAttribute(logBody, "service.response_code"),
"service.response_code should be 200");

// NEW behavior: anonymous user ID and OK auth status
assertEquals("anonymous", getAttribute(logBody, "user.id"),
"user.id should be 'anonymous' for bypass routes");
assertEquals("OK", getAttribute(logBody, "auth.status"),
"auth.status should be 'OK' for bypass routes");

// Error attributes should NOT be present for successful bypass
assertNull(getAttribute(logBody, "auth.error.type"),
"auth.error.type should be null for successful bypass");
assertNull(getAttribute(logBody, "auth.error.message"),
"auth.error.message should be null for successful bypass");
}

@Test
void thenLogWithErrorAttributesOnRoutingError() {
given()
.get(basePath + "/testservicebp/api/v1/500")
.then()
.statusCode(500);

var logRecord = assertOneLogRecordExported("/testservicebp/api/v1/500");
@SuppressWarnings("null")
var logBody = logRecord.getBodyValue().asString();

// Standard attributes still present
assertEquals("GET", getAttribute(logBody, "http.request.method"));
assertEquals("https", getAttribute(logBody, "url.scheme"));
assertEquals("/testservicebp/api/v1/500", getAttribute(logBody, "url.path"));
assertEquals("testservicebp", getAttribute(logBody, "service.id"));
assertEquals("localhost:testservicebp:" + mockServiceBypass.getPort(),
getAttribute(logBody, "service.instance.id"));
assertEquals("bypass", getAttribute(logBody, "auth.service.auth.method"));
assertEquals("500", getAttribute(logBody, "service.response_code"),
"service.response_code should be 500 for error endpoint");

// Anonymous user ID and OK auth status still apply (bypass auth succeeded)
assertEquals("anonymous", getAttribute(logBody, "user.id"),
"user.id should be 'anonymous' for bypass routes even on error");
assertEquals("OK", getAttribute(logBody, "auth.status"),
"auth.status should be 'OK' for bypass routes even on error");

// Error attributes: service error but auth succeeded, so no auth.error attributes
assertNull(getAttribute(logBody, "auth.error.type"),
"auth.error.type should be null — bypass auth always succeeds");
assertNull(getAttribute(logBody, "auth.error.message"),
"auth.error.message should be null — bypass auth always succeeds");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -538,10 +538,10 @@ void thenLog() {
assertAttributesBase(logRecord.getResource().getAttributes(), port);
@SuppressWarnings("null")
var logBody = logRecord.getBodyValue().asString();
assertNull(getAttribute(logBody, "user.id"));
assertEquals("anonymous", getAttribute(logBody, "user.id"));
assertEquals("testservicebp", getAttribute(logBody, "service.id"));
assertEquals("GET", getAttribute(logBody, "http.request.method"));
assertNull(getAttribute(logBody, "auth.status"));
assertEquals("OK", getAttribute(logBody, "auth.status"));
assertEquals("localhost:testservicebp:" + mockServiceBypass.getPort(), getAttribute(logBody, "service.instance.id"));
assertEquals("200", getAttribute(logBody, "service.response_code"));
assertEquals("/testservicebp/api/v1/200", getAttribute(logBody, "url.path"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ public OtelServiceFilterFactory() {
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
OtelRequestContext.of(exchange)
var ctx = OtelRequestContext.of(exchange)
.authMethod(AuthenticationScheme.BYPASS)
.serviceId(config.serviceId)
.instanceId(config.instanceId);

if (AuthenticationScheme.BYPASS.name().equalsIgnoreCase(config.authenticationScheme)) {
ctx.anonymousUserId()
.authenticationSuccess();
}
return chain.filter(exchange);
};
}
Expand All @@ -44,6 +49,7 @@ public static class Config {

private String instanceId;
private String serviceId;
private String authenticationScheme;

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ static <T> List<T> join(List<T> a, List<T> b) {
return output;
}

List<FilterDefinition> getPostRoutingFilters(ServiceInstance serviceInstance, RoutedService routedService) {
List<FilterDefinition> getPostRoutingFilters(ServiceInstance serviceInstance, RoutedService routedService, Authentication auth) {
List<FilterDefinition> serviceRelated = new LinkedList<>();
if (forwardingClientCertEnabled
&& Optional.ofNullable(serviceInstance.getMetadata().get(SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING))
Expand Down Expand Up @@ -162,6 +162,9 @@ List<FilterDefinition> getPostRoutingFilters(ServiceInstance serviceInstance, Ro
otelRequestBasicFilter.setName("OtelServiceFilterFactory");
otelRequestBasicFilter.addArg("serviceId", serviceInstance.getServiceId());
otelRequestBasicFilter.addArg("instanceId", serviceInstance.getInstanceId());
if (auth != null && auth.getScheme() != null) {
Comment thread
balhar-jakub marked this conversation as resolved.
otelRequestBasicFilter.addArg("authenticationScheme", auth.getScheme().name());
}
serviceRelated.add(otelRequestBasicFilter);
}

Expand All @@ -186,7 +189,7 @@ private List<RouteDefinition> getAuthFilterPerRoute(
// generate a new routing rule by a specific produces
RouteDefinition routeDefinition = rdp.get(serviceInstance, routedService);
routeDefinition.setOrder(orderHolder.getAndIncrement());
routeDefinition.getFilters().addAll(getPostRoutingFilters(serviceInstance, routedService));
routeDefinition.getFilters().addAll(getPostRoutingFilters(serviceInstance, routedService, auth));
setAuth(serviceInstance, routeDefinition, auth);

return routeDefinition;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public final class OtelRequestContext {
private static final String OK = "OK";
private static final String ERROR = "ERROR";
public static final String BASIC_AUTH_TYPE = "BASIC";
public static final String ANONYMOUS_USER_ID = "anonymous";

private static final String OTEL_ATTRIBUTE_METHOD = "http.request.method";
private static final String OTEL_ATTRIBUTE_SCHEME = "url.scheme";
Expand Down Expand Up @@ -124,6 +125,10 @@ public OtelRequestContext userId(String userId) {
return put(OTEL_ATTRIBUTE_USER_ID, StringUtils.upperCase(userId));
}

public OtelRequestContext anonymousUserId() {
return put(OTEL_ATTRIBUTE_USER_ID, ANONYMOUS_USER_ID);
}

public OtelRequestContext distributedIds(List<String> distributedIds) {
attributesBuilder.put(OTEL_ATTRIBUTE_DISTRIBUTED_USER_ID, distributedIds.toArray(new String[0]));
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ void givenConfiguredFilter_whenApply_thenSetBypassServiceIdAndInstanceId() {
var config = new OtelServiceFilterFactory.Config();
config.setServiceId(SERVICE_ID);
config.setInstanceId(INSTANCE_ID);
config.setAuthenticationScheme("BYPASS");

new OtelServiceFilterFactory().apply(config).filter(exchange, e -> Mono.empty().then());

var attributes = ((AttributesBuilder) ReflectionTestUtils.getField(OtelRequestContext.of(exchange), "attributesBuilder")).build();
assertEquals("bypass", attributes.get(AttributeKey.stringKey("auth.service.auth.method")));
assertEquals(SERVICE_ID.toLowerCase(), attributes.get(AttributeKey.stringKey("service.id")));
assertEquals(INSTANCE_ID.toLowerCase(), attributes.get(AttributeKey.stringKey("service.instance.id")));
assertEquals("anonymous", attributes.get(AttributeKey.stringKey("user.id")));
assertEquals("OK", attributes.get(AttributeKey.stringKey("auth.status")));
}

}
Loading
Loading