Skip to content

Preserve error code, code name, and error labels when redacting command monitoring/logging #1225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 4 additions & 11 deletions driver-core/src/main/com/mongodb/MongoCommandException.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@

package com.mongodb;

import org.bson.BsonArray;
import org.bson.BsonDocument;
import org.bson.BsonInt32;
import org.bson.BsonString;
import org.bson.codecs.BsonDocumentCodec;
import org.bson.codecs.EncoderContext;
import org.bson.json.JsonWriter;

import java.io.StringWriter;

import static com.mongodb.internal.Exceptions.MongoCommandExceptions.extractErrorCode;
import static com.mongodb.internal.Exceptions.MongoCommandExceptions.extractErrorCodeName;
import static com.mongodb.internal.Exceptions.MongoCommandExceptions.extractErrorLabelsAsBson;
import static java.lang.String.format;

/**
Expand All @@ -50,7 +51,7 @@ public MongoCommandException(final BsonDocument response, final ServerAddress ad
format("Command failed with error %s: '%s' on server %s. The full response is %s", extractErrorCodeAndName(response),
extractErrorMessage(response), address, getResponseAsJson(response)), address);
this.response = response;
addLabels(response.getArray("errorLabels", new BsonArray()));
addLabels(extractErrorLabelsAsBson(response));
}

/**
Expand Down Expand Up @@ -109,14 +110,6 @@ private static String extractErrorCodeAndName(final BsonDocument response) {
}
}

private static int extractErrorCode(final BsonDocument response) {
return response.getNumber("code", new BsonInt32(-1)).intValue();
}

private static String extractErrorCodeName(final BsonDocument response) {
return response.getString("codeName", new BsonString("")).getValue();
}

private static String extractErrorMessage(final BsonDocument response) {
String errorMessage = response.getString("errmsg", new BsonString("")).getValue();
// Satisfy nullability checker
Expand Down
109 changes: 109 additions & 0 deletions driver-core/src/main/com/mongodb/internal/Exceptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright 2008-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.mongodb.internal;

import com.mongodb.MongoCommandException;
import org.bson.BsonArray;
import org.bson.BsonDocument;
import org.bson.BsonInt32;
import org.bson.BsonNumber;
import org.bson.BsonString;
import org.bson.BsonValue;

import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.mongodb.internal.VisibleForTesting.AccessModifier.PRIVATE;

/**
* <p>This class is not part of the public API and may be removed or changed at any time</p>
*/
public final class Exceptions {
public static final class MongoCommandExceptions {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I've never seen this particular nested class pattern before. Is it in anticipation of adding more nested classes in the future for other exception types? Otherwise why not make this a top level class and remove the outer class?

Copy link
Member Author

@stIncMale stIncMale Oct 21, 2023

Choose a reason for hiding this comment

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

I've never seen this particular nested class pattern before.

It's something we started doing in the Kafka connector:

Only at that time I didn't know one must use @Nested, but now IntelliJ warned me and I learned.

Is it in anticipation of adding more nested classes in the future for other exception types?

Yes. Nesting is one more tool for grouping tests: https://stackoverflow.com/questions/36220889/whats-the-purpose-of-the-junit-5-nested-annotation.

P.S. I'll need to add @Nested to the Kafka connector codebase.

Copy link
Member Author

@stIncMale stIncMale Oct 30, 2023

Choose a reason for hiding this comment

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

P.S. I'll need to add @Nested to the Kafka connector codebase.

Done: mongodb/mongo-kafka@62fedf0

Copy link
Member

Choose a reason for hiding this comment

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

From my understanding, the @Nested annotation in JUnit, along with its underlying pattern, is designed not only for the grouping of test classes under a common parent but also the sharing of initialization/fixtures from that parent test class.

Given that we have a static inner class here, I'm curious if there's an intent to introduce static fields or data in the Exceptions class that must be shared with MongoCommandException or potentially with other added classes in the future, thus warranting the coupling?

Copy link
Member Author

Choose a reason for hiding this comment

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

The test classes are nested because the classes they test are. The reasons the tested classes are nested are:

  • I want utilities for specific exception types to be separated from utilities for any exception.
  • On the other hand, I still want utilities for specific exception types to be somehow grouped together, and grouped with utilities for any exception.

I see two ways of achieving the above:

  1. Use nested classes, as currently done in the PR.
  2. Use a new com.mongodb.internal.exception package.

I chose 1, but I am not opposed to 2. Let me know if you think we should do 2, and I'll refactor the code and the test.

Copy link
Member

@vbabanin vbabanin Nov 1, 2023

Choose a reason for hiding this comment

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

Thank you for the clarification. I don't have a strong preference, since nesting or packaging serve the same purpose here. Feel free to decide as you see fit.

public static int extractErrorCode(final BsonDocument response) {
return extractErrorCodeAsBson(response).intValue();
}

public static String extractErrorCodeName(final BsonDocument response) {
return extractErrorCodeNameAsBson(response).getValue();
}

public static BsonArray extractErrorLabelsAsBson(final BsonDocument response) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Optional: move this down near the other asBson-suffixed private methods. It caused some cognitive dissonance during review to see it here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Unlike those methods, this one is public, and I organized the methods such that private ones are ordered after the public methods. I reordered the methods.

return response.getArray("errorLabels", new BsonArray());
}

/**
* Constructs a {@link MongoCommandException} with the data from the {@code original} redacted for security purposes.
*/
public static MongoCommandException redacted(final MongoCommandException original) {
BsonDocument originalResponse = original.getResponse();
BsonDocument redactedResponse = new BsonDocument();
for (SecurityInsensitiveResponseField field : SecurityInsensitiveResponseField.values()) {
redactedResponse.append(field.fieldName(), field.fieldValue(originalResponse));
}
MongoCommandException result = new MongoCommandException(redactedResponse, original.getServerAddress());
result.setStackTrace(original.getStackTrace());
return result;
}

private static BsonNumber extractErrorCodeAsBson(final BsonDocument response) {
return response.getNumber("code", new BsonInt32(-1));
}

private static BsonString extractErrorCodeNameAsBson(final BsonDocument response) {
return response.getString("codeName", new BsonString(""));
}

@VisibleForTesting(otherwise = PRIVATE)
public enum SecurityInsensitiveResponseField {
CODE("code", MongoCommandExceptions::extractErrorCodeAsBson),
CODE_NAME("codeName", MongoCommandExceptions::extractErrorCodeNameAsBson),
ERROR_LABELS("errorLabels", MongoCommandExceptions::extractErrorLabelsAsBson);

private final String fieldName;
private final Function<BsonDocument, BsonValue> fieldValueExtractor;

SecurityInsensitiveResponseField(final String fieldName, final Function<BsonDocument, BsonValue> fieldValueExtractor) {
this.fieldName = fieldName;
this.fieldValueExtractor = fieldValueExtractor;
}

String fieldName() {
return fieldName;
}

BsonValue fieldValue(final BsonDocument response) {
return fieldValueExtractor.apply(response);
}

@VisibleForTesting(otherwise = PRIVATE)
public static Set<String> fieldNames() {
return Stream.of(SecurityInsensitiveResponseField.values())
.map(SecurityInsensitiveResponseField::fieldName)
.collect(Collectors.toSet());
}
}

private MongoCommandExceptions() {
}
}

private Exceptions() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.mongodb.connection.ClusterId;
import com.mongodb.connection.ConnectionDescription;
import com.mongodb.event.CommandListener;
import com.mongodb.internal.Exceptions.MongoCommandExceptions;
import com.mongodb.internal.logging.LogMessage;
import com.mongodb.internal.logging.LogMessage.Entry;
import com.mongodb.internal.logging.StructuredLogger;
Expand Down Expand Up @@ -124,9 +125,7 @@ public void sendStartedEvent() {
public void sendFailedEvent(final Throwable t) {
Throwable commandEventException = t;
if (t instanceof MongoCommandException && redactionRequired) {
MongoCommandException originalCommandException = (MongoCommandException) t;
commandEventException = new MongoCommandException(new BsonDocument(), originalCommandException.getServerAddress());
commandEventException.setStackTrace(t.getStackTrace());
commandEventException = MongoCommandExceptions.redacted((MongoCommandException) t);
}
long elapsedTimeNanos = System.nanoTime() - startTimeNanos;

Expand Down
63 changes: 63 additions & 0 deletions driver-core/src/test/unit/com/mongodb/internal/ExceptionsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2008-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.mongodb.internal;

import com.mongodb.MongoCommandException;
import com.mongodb.ServerAddress;
import com.mongodb.internal.Exceptions.MongoCommandExceptions;
import org.bson.BsonArray;
import org.bson.BsonBoolean;
import org.bson.BsonDocument;
import org.bson.BsonInt32;
import org.bson.BsonString;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.util.HashSet;

import static java.util.Arrays.asList;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

final class ExceptionsTest {
@Nested
final class MongoCommandExceptionsTest {
@Test
void redacted() {
MongoCommandException original = new MongoCommandException(
new BsonDocument("ok", BsonBoolean.FALSE)
.append("code", new BsonInt32(26))
.append("codeName", new BsonString("TimeoutError"))
.append("errorLabels", new BsonArray(asList(new BsonString("label"), new BsonString("label2"))))
.append("errmsg", new BsonString("err msg")),
new ServerAddress());
MongoCommandException redacted = MongoCommandExceptions.redacted(original);
assertArrayEquals(original.getStackTrace(), redacted.getStackTrace());
String message = redacted.getMessage();
assertTrue(message.contains("26"));
assertTrue(message.contains("TimeoutError"));
assertTrue(message.contains("label"));
assertFalse(message.contains("err msg"));
assertTrue(redacted.getErrorMessage().isEmpty());
assertEquals(26, redacted.getErrorCode());
assertEquals("TimeoutError", redacted.getErrorCodeName());
assertEquals(new HashSet<>(asList("label", "label2")), redacted.getErrorLabels());
assertEquals(MongoCommandExceptions.SecurityInsensitiveResponseField.fieldNames(), redacted.getResponse().keySet());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.mongodb.connection.StreamFactory
import com.mongodb.event.CommandFailedEvent
import com.mongodb.event.CommandStartedEvent
import com.mongodb.event.CommandSucceededEvent
import com.mongodb.internal.Exceptions
import com.mongodb.internal.IgnorableRequestContext
import com.mongodb.internal.session.SessionContext
import com.mongodb.internal.validator.NoOpFieldNameValidator
Expand Down Expand Up @@ -875,7 +876,7 @@ class InternalStreamConnectionSpecification extends Specification {
]
}

def 'should send failed event with elided exception in failed security-sensitive commands'() {
def 'should send failed event with redacted exception in failed security-sensitive commands'() {
given:
def connection = getOpenedConnection()
def commandMessage = new CommandMessage(cmdNamespace, securitySensitiveCommand, fieldNameValidator, primary(), messageSettings,
Expand All @@ -893,7 +894,8 @@ class InternalStreamConnectionSpecification extends Specification {
CommandFailedEvent failedEvent = commandListener.getEvents().get(1)
failedEvent.throwable.class == MongoCommandException
MongoCommandException e = failedEvent.throwable
e.response == new BsonDocument()
Exceptions.MongoCommandExceptions.SecurityInsensitiveResponseField.fieldNames()
.containsAll(e.getResponse().keySet())

where:
securitySensitiveCommand << [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.mongodb.client.unified;

import com.mongodb.MongoCommandException;
import com.mongodb.internal.Exceptions.MongoCommandExceptions;
import com.mongodb.internal.logging.LogMessage;
import org.bson.BsonArray;
import org.bson.BsonBoolean;
Expand Down Expand Up @@ -79,7 +80,9 @@ static BsonDocument asDocument(final LogMessage message) {
}

private static boolean exceptionIsRedacted(final Throwable exception) {
return exception instanceof MongoCommandException && ((MongoCommandException) exception).getResponse().isEmpty();
return exception instanceof MongoCommandException
&& MongoCommandExceptions.SecurityInsensitiveResponseField.fieldNames()
.containsAll(((MongoCommandException) exception).getResponse().keySet());
}

private static BsonValue asBsonValue(final Object value) {
Expand Down