Skip to content
Merged
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 @@ -115,6 +115,10 @@ public class KafkaSecurityConfigurer {
private static AwsCredentialsProvider awsGlueCredentialsProvider;
private static GlueSchemaRegistryKafkaDeserializer glueDeserializer;

static AwsCredentialsProvider getMskCredentialsProvider() {
return mskCredentialsProvider;
}


/*public static void setSaslPlainTextProperties(final KafkaSourceConfig kafkaSourConfig,
final Properties properties) {
Expand Down Expand Up @@ -245,18 +249,8 @@ public static void setAwsIamAuthProperties(Properties properties, final AwsIamAu
if (Objects.isNull(awsConfig)) {
throw new RuntimeException("AWS Config needs to be specified when sasl/aws_msk_iam is set to \"role\"");
}
String baseIamAuthConfig = "software.amazon.msk.auth.iam.IAMLoginModule required " +
"awsRoleArn=\"%s\" " +
"awsStsRegion=\"%s\"";

baseIamAuthConfig = String.format(baseIamAuthConfig, awsConfig.getStsRoleArn(), awsConfig.getRegion());

if (Objects.nonNull(awsConfig.getStsRoleSessionName())) {
baseIamAuthConfig += String.format(" awsRoleSessionName=\"%s\"", awsConfig.getStsRoleSessionName());
}

baseIamAuthConfig += ";";
properties.put(SASL_JAAS_CONFIG, baseIamAuthConfig);
properties.put(SASL_CLIENT_CALLBACK_HANDLER_CLASS, MskIamAuthCredentialsCallbackHandler.class.getName());
properties.put(SASL_JAAS_CONFIG, "software.amazon.msk.auth.iam.IAMLoginModule required;");
} else if (awsIamAuthConfig == AwsIamAuthConfig.DEFAULT) {
properties.put(SASL_JAAS_CONFIG,
"software.amazon.msk.auth.iam.IAMLoginModule required;");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
*/

package org.opensearch.dataprepper.plugins.kafka.util;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.auth.BasicSessionCredentials;
import org.apache.kafka.common.security.auth.AuthenticateCallbackHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.msk.auth.iam.IAMLoginModule;
import software.amazon.msk.auth.iam.internals.AWSCredentialsCallback;

import javax.security.auth.callback.Callback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.AppConfigurationEntry;
import java.util.List;
import java.util.Map;

/**
* SASL client callback handler for the {@code AWS_MSK_IAM} mechanism that sources credentials
* exclusively from Data Prepper's own header-aware STS provider, so the configured
* {@code aws.sts_header_overrides} are applied on the data-plane {@code sts:AssumeRole} used for

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was curious how this even worked since I don't see this here.

We already have code to use the STS headers. They are just not used on communicating with the Kafka client directly.

Map<String, String> headers = awsConfig.getAwsStsHeaderOverrides();
if (Objects.nonNull(headers)) {
assumeRequestBuilder.overrideConfiguration(configuration -> {
headers.forEach(configuration::putHeader);
});
}
mskCredentialsProvider = StsAssumeRoleCredentialsProvider
.builder()
.stsClient(stsClient)
.refreshRequest(assumeRequestBuilder.build())
.build();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, that STS-header code is only used for the initial GetBootstrapBrokers call. The Kafka client's own SASL handshake goes through the vendored IAMClientCallbackHandler, which builds its own provider from the JAAS role with no header overrides — so the data-plane AssumeRole was missing the headers. This callback handler is what routes that handshake through the header-aware provider too.

* broker authentication.
*/
public class MskIamAuthCredentialsCallbackHandler implements AuthenticateCallbackHandler {
private static final Logger LOG = LoggerFactory.getLogger(MskIamAuthCredentialsCallbackHandler.class);

@Override
public void configure(final Map<String, ?> configs,
final String saslMechanism,
final List<AppConfigurationEntry> jaasConfigEntries) {
if (!IAMLoginModule.MECHANISM.equals(saslMechanism)) {
throw new IllegalArgumentException("Unexpected SASL mechanism: " + saslMechanism);
}
}

@Override
public void handle(final Callback[] callbacks) throws UnsupportedCallbackException {
for (final Callback callback : callbacks) {
if (callback instanceof AWSCredentialsCallback) {
handleCallback((AWSCredentialsCallback) callback);
} else {
throw new UnsupportedCallbackException(callback,
"Unsupported callback type: " + callback.getClass().getName());
}
}
}

private void handleCallback(final AWSCredentialsCallback callback) {
final AwsCredentialsProvider provider = KafkaSecurityConfigurer.getMskCredentialsProvider();
try {
LOG.debug("Resolving MSK IAM credentials from header-aware provider {}",
provider == null ? "null" : provider.getClass().getSimpleName());
callback.setAwsCredentials(toV1Credentials(provider.resolveCredentials()));
} catch (final Exception e) {
LOG.debug("Failed to resolve MSK IAM credentials with STS header overrides", e);
callback.setLoadingException(e);
}
}

@Override
public void close() {
}

private static AWSCredentials toV1Credentials(final AwsCredentials credentials) {
if (credentials instanceof AwsSessionCredentials) {
final AwsSessionCredentials sessionCredentials = (AwsSessionCredentials) credentials;
return new BasicSessionCredentials(
sessionCredentials.accessKeyId(),
sessionCredentials.secretAccessKey(),
sessionCredentials.sessionToken());
}
return new BasicAWSCredentials(credentials.accessKeyId(), credentials.secretAccessKey());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,29 @@ public void testSetAuthPropertiesBootstrapServersWithSaslIAMRole() throws IOExce
assertThat(props.getProperty("bootstrap.servers"), is("localhost:9092"));
assertThat(props.getProperty("sasl.mechanism"), is("AWS_MSK_IAM"));
assertThat(props.getProperty("sasl.jaas.config"),
is("software.amazon.msk.auth.iam.IAMLoginModule required " +
"awsRoleArn=\"test_sasl_iam_sts_role\" awsStsRegion=\"us-east-2\";"));
is("software.amazon.msk.auth.iam.IAMLoginModule required;"));
assertThat(props.getProperty("security.protocol"), is("SASL_SSL"));
assertThat(props.getProperty("certificateContent"), is(nullValue()));
assertThat(props.getProperty("ssl.truststore.location"), is(nullValue()));
assertThat(props.getProperty("ssl.truststore.password"), is(nullValue()));
assertThat(props.get("ssl.engine.factory.class"), is(nullValue()));
assertThat(props.get("sasl.client.callback.handler.class"),
is("software.amazon.msk.auth.iam.IAMClientCallbackHandler"));
is(MskIamAuthCredentialsCallbackHandler.class.getName()));
}

@Test
public void testSetAuthPropertiesBootstrapServersWithSaslIAMRoleAndStsHeaderOverrides() throws IOException {
final Properties props = new Properties();
final KafkaSourceConfig kafkaSourceConfig =
createKafkaSinkConfig("kafka-pipeline-bootstrap-servers-sasl-iam-role-with-headers.yaml");
KafkaSecurityConfigurer.setAuthProperties(props, kafkaSourceConfig, null, LOG);
assertThat(props.getProperty("bootstrap.servers"), is("localhost:9092"));
assertThat(props.getProperty("sasl.mechanism"), is("AWS_MSK_IAM"));
assertThat(props.getProperty("security.protocol"), is("SASL_SSL"));
assertThat(props.getProperty("sasl.jaas.config"),
is("software.amazon.msk.auth.iam.IAMLoginModule required;"));
assertThat(props.get("sasl.client.callback.handler.class"),
is(MskIamAuthCredentialsCallbackHandler.class.getName()));
}

@Test
Expand Down Expand Up @@ -230,14 +244,14 @@ public void testSetAuthPropertiesBootstrapServersOverrideByMSK() throws IOExcept
assertThat(props.getProperty("bootstrap.servers"), is(testMSKEndpoint));
assertThat(props.getProperty("sasl.mechanism"), is("AWS_MSK_IAM"));
assertThat(props.getProperty("sasl.jaas.config"),
is("software.amazon.msk.auth.iam.IAMLoginModule required awsRoleArn=\"sts_role_arn\" awsStsRegion=\"us-east-2\";"));
is("software.amazon.msk.auth.iam.IAMLoginModule required;"));
assertThat(props.getProperty("security.protocol"), is("SASL_SSL"));
assertThat(props.getProperty("certificateContent"), is(nullValue()));
assertThat(props.getProperty("ssl.truststore.location"), is(nullValue()));
assertThat(props.getProperty("ssl.truststore.password"), is(nullValue()));
assertThat(props.get("ssl.engine.factory.class"), is(nullValue()));
assertThat(props.get("sasl.client.callback.handler.class"),
is("software.amazon.msk.auth.iam.IAMClientCallbackHandler"));
is(MskIamAuthCredentialsCallbackHandler.class.getName()));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
*/
package org.opensearch.dataprepper.plugins.kafka.util;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.auth.BasicSessionCredentials;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.msk.auth.iam.internals.AWSCredentialsCallback;

import javax.security.auth.callback.Callback;
import javax.security.auth.callback.UnsupportedCallbackException;
import java.util.Collections;

import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.sameInstance;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class MskIamAuthCredentialsCallbackHandlerTest {

@Mock
private AwsCredentialsProvider mskCredentialsProvider;

private final MskIamAuthCredentialsCallbackHandler objectUnderTest = new MskIamAuthCredentialsCallbackHandler();

@Test
void handleResolvesSessionCredentialsAndConvertsToV1() throws Exception {
final AwsSessionCredentials sessionCredentials = AwsSessionCredentials.create(
"accessKeyId", "secretAccessKey", "sessionToken");
when(mskCredentialsProvider.resolveCredentials()).thenReturn(sessionCredentials);
final AWSCredentialsCallback callback = new AWSCredentialsCallback();

try (final MockedStatic<KafkaSecurityConfigurer> mockedConfigurer = mockStatic(KafkaSecurityConfigurer.class)) {
mockedConfigurer.when(KafkaSecurityConfigurer::getMskCredentialsProvider).thenReturn(mskCredentialsProvider);
objectUnderTest.handle(new Callback[]{callback});
}

assertThat(callback.isSuccessful(), is(true));
final AWSCredentials credentials = callback.getAwsCredentials();
assertThat(credentials, instanceOf(BasicSessionCredentials.class));
assertThat(credentials.getAWSAccessKeyId(), is("accessKeyId"));
assertThat(credentials.getAWSSecretKey(), is("secretAccessKey"));
assertThat(((BasicSessionCredentials) credentials).getSessionToken(), is("sessionToken"));
}

@Test
void handleResolvesNonSessionCredentialsAndConvertsToV1() throws Exception {
final AwsBasicCredentials basicCredentials = AwsBasicCredentials.create("accessKeyId", "secretAccessKey");
when(mskCredentialsProvider.resolveCredentials()).thenReturn(basicCredentials);
final AWSCredentialsCallback callback = new AWSCredentialsCallback();

try (final MockedStatic<KafkaSecurityConfigurer> mockedConfigurer = mockStatic(KafkaSecurityConfigurer.class)) {
mockedConfigurer.when(KafkaSecurityConfigurer::getMskCredentialsProvider).thenReturn(mskCredentialsProvider);
objectUnderTest.handle(new Callback[]{callback});
}

assertThat(callback.isSuccessful(), is(true));
final AWSCredentials credentials = callback.getAwsCredentials();
assertThat(credentials, instanceOf(BasicAWSCredentials.class));
assertThat(credentials.getAWSAccessKeyId(), is("accessKeyId"));
assertThat(credentials.getAWSSecretKey(), is("secretAccessKey"));
}

@Test
void handleSetsLoadingExceptionWhenProviderThrows() throws Exception {
final RuntimeException resolveException = new RuntimeException("Access denied");
when(mskCredentialsProvider.resolveCredentials()).thenThrow(resolveException);
final AWSCredentialsCallback callback = new AWSCredentialsCallback();

try (final MockedStatic<KafkaSecurityConfigurer> mockedConfigurer = mockStatic(KafkaSecurityConfigurer.class)) {
mockedConfigurer.when(KafkaSecurityConfigurer::getMskCredentialsProvider).thenReturn(mskCredentialsProvider);
objectUnderTest.handle(new Callback[]{callback});
}

assertThat(callback.isSuccessful(), is(false));
assertThat(callback.getLoadingException(), is(sameInstance(resolveException)));
}

@Test
void handleSetsLoadingExceptionWhenProviderIsNull() throws Exception {
final AWSCredentialsCallback callback = new AWSCredentialsCallback();

try (final MockedStatic<KafkaSecurityConfigurer> mockedConfigurer = mockStatic(KafkaSecurityConfigurer.class)) {
mockedConfigurer.when(KafkaSecurityConfigurer::getMskCredentialsProvider).thenReturn(null);
objectUnderTest.handle(new Callback[]{callback});
}

assertThat(callback.isSuccessful(), is(false));
assertThat(callback.getLoadingException(), instanceOf(NullPointerException.class));
}

@Test
void configureAcceptsTheMskIamMechanismAndReadsNoJaasOptions() {
assertDoesNotThrow(() -> objectUnderTest.configure(
Collections.emptyMap(), "AWS_MSK_IAM", Collections.emptyList()));
}

@Test
void configureRejectsAnUnexpectedMechanism() {
assertThrows(IllegalArgumentException.class, () -> objectUnderTest.configure(
Collections.emptyMap(), "SCRAM-SHA-512", Collections.emptyList()));
}

@Test
void handleRejectsAnUnsupportedCallbackType() {
final Callback unsupported = new Callback() { };
assertThrows(UnsupportedCallbackException.class,
() -> objectUnderTest.handle(new Callback[]{unsupported}));
}
}
Loading