Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -79,7 +79,7 @@ public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
.switchIfEmpty(Mono.<AbstractAuthenticationToken>defer(() -> chain.filter(exchange).then(Mono.empty())))
.flatMap(credentials -> {
var otelContext = OtelRequestContext.of(exchange);
otelContext.authSourceType(OtelRequestContext.BASIC_AUTH_TYPE);
otelContext.authSourceType(OtelRequestContext.AUTH_TYPE_BASIC);
return authenticationManager.authenticate(credentials)
.flatMap(authentication -> chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* 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.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.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.*;

/**
* Verifies that bypass-authentication routes emit OTel log signals with
* {@code user.id=anonymous} and {@code auth.status=OK} instead of null
* attributes (see #4704). Covers both successful (200) and downstream
* error (500) scenarios.
*/
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"
}
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WhenAnonymousBypassRequest extends AcceptanceTestWithMockServices {

@Autowired
private InMemoryLogRecordExporter 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() {
logExporter.reset();
}

private List<LogRecordData> assertLogsExported() {
List<LogRecordData> logs = new ArrayList<>();
await("Log export")
.atMost(Duration.ofSeconds(10))
.until(() -> {
var l = logExporter.getFinishedLogRecordItems();
if (l.size() > 0) {
logs.addAll(l);
}
logExporter.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(() -> {
var availableUrls = logs.stream()
.map(log -> log.getBodyValue().asString())
.collect(Collectors.joining(", "));
return new AssertionError(
"Expected log record with URL " + expectedUrl + " not found in logs: " + availableUrls);
});

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

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

return logRecord;
}

private Map<String, Object> parseLogBody(String logBody) {
try {
return new ObjectMapper().readValue(logBody, Map.class);
} catch (JsonProcessingException e) {
fail("Invalid JSON: " + logBody, e);
return Map.of();
}
}

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

var logRecord = assertOneLogRecordExported("/testservicebp/api/v1/200");
@SuppressWarnings("null")
Map<String, Object> logBody = parseLogBody(logRecord.getBodyValue().asString());

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

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

// Error attributes should NOT be present for successful bypass
assertNull(logBody.get("auth.error.type"),
"auth.error.type should be null for successful bypass");
assertNull(logBody.get("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")
Map<String, Object> logBody = parseLogBody(logRecord.getBodyValue().asString());

// Standard attributes still present
assertEquals("GET", logBody.get("http.request.method"));
assertEquals("https", logBody.get("url.scheme"));
assertEquals("/testservicebp/api/v1/500", logBody.get("url.path"));
assertEquals("testservicebp", logBody.get("service.id"));
assertEquals("localhost:testservicebp:" + mockServiceBypass.getPort(),
logBody.get("service.instance.id"));
assertEquals("bypass", logBody.get("auth.service.auth.method"));
assertEquals("500", logBody.get("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", logBody.get("user.id"),
"user.id should be 'anonymous' for bypass routes even on error");
assertEquals("OK", logBody.get("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(logBody.get("auth.error.type"),
"auth.error.type should be null — bypass auth always succeeds");
assertNull(logBody.get("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,8 @@ public static class Config {

private String instanceId;
private String serviceId;
/** The {@link AuthenticationScheme#name()} for this route, or null if unavailable. */
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 @@ -31,9 +31,10 @@ public final class OtelRequestContext {

public static final String OTEL_CONTEXT = "otel-context";

private static final String OK = "OK";
private static final String ERROR = "ERROR";
public static final String BASIC_AUTH_TYPE = "BASIC";
public static final String AUTH_STATUS_OK = "OK";
public static final String AUTH_STATUS_ERROR = "ERROR";
public static final String AUTH_TYPE_BASIC = "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 @@ -105,7 +106,7 @@ public OtelRequestContext authMethod(String authenticationScheme) {
}

public OtelRequestContext authenticationFailed() {
return put(OTEL_ATTRIBUTE_AUTH_STATUS, ERROR);
return put(OTEL_ATTRIBUTE_AUTH_STATUS, AUTH_STATUS_ERROR);
}

public OtelRequestContext authErrorType(String authErrorType) {
Expand All @@ -117,13 +118,17 @@ public OtelRequestContext authErrorMessage(String authErrorMessage) {
}

public OtelRequestContext authenticationSuccess() {
return put(OTEL_ATTRIBUTE_AUTH_STATUS, OK);
return put(OTEL_ATTRIBUTE_AUTH_STATUS, AUTH_STATUS_OK);
}

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 @@ -19,7 +19,7 @@
import org.zowe.apiml.product.opentelemetry.OtelRequestContext;
import reactor.core.publisher.Mono;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.*;

class OtelServiceFilterFactoryTest {

Expand All @@ -34,13 +34,57 @@ 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(OtelRequestContext.AUTH_STATUS_OK, attributes.get(AttributeKey.stringKey("auth.status")));
}

@Test
void givenConfiguredFilterWithoutBypass_whenApply_thenDoNotSetAnonymousUserAndAuthStatus() {
MockServerHttpRequest request = MockServerHttpRequest.get("/aPath").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);

var config = new OtelServiceFilterFactory.Config();
config.setServiceId(SERVICE_ID);
config.setInstanceId(INSTANCE_ID);
// authenticationScheme left unset (null) — not a BYPASS route

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")));
// user.id and auth.status should remain unset for non-BYPASS routes
assertNull(attributes.get(AttributeKey.stringKey("user.id")));
assertNull(attributes.get(AttributeKey.stringKey("auth.status")));
}

@Test
void givenConfiguredFilterWithNonBypassAuth_whenApply_thenDoNotSetAnonymousUserAndAuthStatus() {
MockServerHttpRequest request = MockServerHttpRequest.get("/aPath").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);

var config = new OtelServiceFilterFactory.Config();
config.setServiceId(SERVICE_ID);
config.setInstanceId(INSTANCE_ID);
config.setAuthenticationScheme("ZOWE_JWT"); // explicitly non-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")));
assertNull(attributes.get(AttributeKey.stringKey("user.id")));
assertNull(attributes.get(AttributeKey.stringKey("auth.status")));
}

}
Loading
Loading