This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 4fc67338ab8 branch-4.1: [feature](RoutineLoad) Support RoutineLoad IAM
auth #61324 (#62245)
4fc67338ab8 is described below
commit 4fc67338ab880cea23e513a1c7baf62a7a227d85
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Apr 9 11:27:27 2026 +0800
branch-4.1: [feature](RoutineLoad) Support RoutineLoad IAM auth #61324
(#62245)
Cherry-picked from #61324
Co-authored-by: 黄瑞鑫 <[email protected]>
---
be/src/load/routine_load/data_consumer.cpp | 43 +-
be/src/load/routine_load/data_consumer.h | 4 +
be/src/runtime/aws_msk_iam_auth.cpp | 517 +++++++++++++++++++++
be/src/runtime/aws_msk_iam_auth.h | 169 +++++++
be/test/runtime/aws_msk_iam_auth_test.cpp | 244 ++++++++++
.../load/routineload/kafka/KafkaConfiguration.java | 15 +
.../kafka/KafkaDataSourceProperties.java | 80 +++-
.../load/routineload/KafkaAwsMskIamAuthTest.java | 491 +++++++++++++++++++
regression-test/conf/regression-conf.groovy | 1 +
.../load_p0/msk_load/test_routine_load_msk.groovy | 177 +++++++
.../test_routine_load_msk_public_access.groovy | 105 +++++
11 files changed, 1839 insertions(+), 7 deletions(-)
diff --git a/be/src/load/routine_load/data_consumer.cpp
b/be/src/load/routine_load/data_consumer.cpp
index c3732ffee39..1f7265b2d8a 100644
--- a/be/src/load/routine_load/data_consumer.cpp
+++ b/be/src/load/routine_load/data_consumer.cpp
@@ -33,6 +33,7 @@
#include "common/config.h"
#include "common/metrics/doris_metrics.h"
#include "common/status.h"
+#include "runtime/aws_msk_iam_auth.h"
#include "runtime/exec_env.h"
#include "runtime/small_file_mgr.h"
#include "service/backend_options.h"
@@ -99,6 +100,15 @@ Status
KafkaDataConsumer::init(std::shared_ptr<StreamLoadContext> ctx) {
}
for (auto& item : ctx->kafka_info->properties) {
+ _custom_properties.emplace(item.first, item.second);
+
+ // AWS properties (aws.*) are Doris-specific for MSK IAM authentication
+ // and should not be passed to librdkafka
+ if (starts_with(item.first, "aws.")) {
+ LOG(INFO) << "Skipping AWS property for librdkafka: " <<
item.first;
+ continue;
+ }
+
if (starts_with(item.second, "FILE:")) {
// file property should has format: FILE:file_id:md5
std::vector<std::string> parts =
@@ -118,13 +128,12 @@ Status
KafkaDataConsumer::init(std::shared_ptr<StreamLoadContext> ctx) {
} else {
RETURN_IF_ERROR(set_conf(item.first, item.second));
}
- _custom_properties.emplace(item.first, item.second);
}
// if not specified group id, generate a random one.
// ATTN: In the new version, we have set a group.id on the FE side for
jobs that have not set a groupid,
// but in order to ensure compatibility, we still do a check here.
- if (_custom_properties.find(PROP_GROUP_ID) == _custom_properties.end()) {
+ if (!_custom_properties.contains(PROP_GROUP_ID)) {
std::stringstream ss;
ss << BackendOptions::get_localhost() << "_";
std::string group_id = ss.str() + UniqueId::gen_uid().to_string();
@@ -140,6 +149,24 @@ Status
KafkaDataConsumer::init(std::shared_ptr<StreamLoadContext> ctx) {
return Status::InternalError(ss.str());
}
+ // Set up AWS MSK IAM authentication if configured
+ _aws_msk_oauth_callback = AwsMskIamOAuthCallback::create_from_properties(
+ _custom_properties, ctx->kafka_info->brokers);
+ if (_aws_msk_oauth_callback) {
+ // Enable SASL queue to support background callbacks
+ if (conf->enable_sasl_queue(true, errstr) != RdKafka::Conf::CONF_OK) {
+ LOG(WARNING) << "PAUSE: failed to enable SASL queue: " << errstr;
+ return Status::InternalError("PAUSE: failed to enable SASL queue:
" + errstr);
+ }
+
+ if (conf->set("oauthbearer_token_refresh_cb",
_aws_msk_oauth_callback.get(), errstr) !=
+ RdKafka::Conf::CONF_OK) {
+ LOG(WARNING) << "PAUSE: failed to set OAuth callback: " << errstr;
+ return Status::InternalError("PAUSE: failed to set OAuth callback:
" + errstr);
+ }
+ LOG(INFO) << "AWS MSK IAM authentication enabled successfully";
+ }
+
// create consumer
_k_consumer = RdKafka::KafkaConsumer::create(conf, errstr);
if (!_k_consumer) {
@@ -147,6 +174,18 @@ Status
KafkaDataConsumer::init(std::shared_ptr<StreamLoadContext> ctx) {
return Status::InternalError("PAUSE: failed to create kafka consumer:
" + errstr);
}
+ // If AWS MSK IAM auth is enabled, inject initial token and enable
background refresh
+ if (_aws_msk_oauth_callback) {
+ RETURN_IF_ERROR(_aws_msk_oauth_callback->refresh_now(_k_consumer));
+
+ std::unique_ptr<RdKafka::Error>
bg_err(_k_consumer->sasl_background_callbacks_enable());
+ if (bg_err) {
+ return Status::InternalError("Failed to enable SASL background
callbacks: " +
+ bg_err->str());
+ }
+ LOG(INFO) << "AWS MSK IAM: initial token set, background refresh
enabled";
+ }
+
VLOG_NOTICE << "finished to init kafka consumer. " << ctx->brief();
_init = true;
diff --git a/be/src/load/routine_load/data_consumer.h
b/be/src/load/routine_load/data_consumer.h
index 6e223e07d98..2be8e3d23ec 100644
--- a/be/src/load/routine_load/data_consumer.h
+++ b/be/src/load/routine_load/data_consumer.h
@@ -32,6 +32,7 @@
#include "common/status.h"
#include "librdkafka/rdkafkacpp.h"
#include "load/stream_load/stream_load_context.h"
+#include "runtime/aws_msk_iam_auth.h"
#include "util/uid_util.h"
namespace doris {
@@ -167,6 +168,9 @@ private:
KafkaEventCb _k_event_cb;
RdKafka::KafkaConsumer* _k_consumer = nullptr;
+
+ // AWS MSK IAM authentication callback (must outlive _k_consumer)
+ std::unique_ptr<AwsMskIamOAuthCallback> _aws_msk_oauth_callback;
};
} // end namespace doris
diff --git a/be/src/runtime/aws_msk_iam_auth.cpp
b/be/src/runtime/aws_msk_iam_auth.cpp
new file mode 100644
index 00000000000..6d6292b7b01
--- /dev/null
+++ b/be/src/runtime/aws_msk_iam_auth.cpp
@@ -0,0 +1,517 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+#include "runtime/aws_msk_iam_auth.h"
+
+#include <aws/core/auth/AWSCredentials.h>
+#include <aws/core/auth/AWSCredentialsProvider.h>
+#include <aws/core/auth/AWSCredentialsProviderChain.h>
+#include <aws/core/auth/STSCredentialsProvider.h>
+#include <aws/core/platform/Environment.h>
+#include <aws/identity-management/auth/STSAssumeRoleCredentialsProvider.h>
+#include <aws/sts/STSClient.h>
+#include <aws/sts/model/AssumeRoleRequest.h>
+#include <openssl/hmac.h>
+#include <openssl/sha.h>
+
+#include <algorithm>
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+#include "common/logging.h"
+
+namespace doris {
+
+AwsMskIamAuth::AwsMskIamAuth(Config config) : _config(std::move(config)) {
+ _credentials_provider = _create_credentials_provider();
+}
+
+std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
AwsMskIamAuth::_create_provider_from_type(
+ const std::string& provider_type) {
+ std::string provider_upper = provider_type;
+ std::transform(provider_upper.begin(), provider_upper.end(),
provider_upper.begin(), ::toupper);
+
+ if (provider_upper == "ENV" || provider_upper == "ENVIRONMENT") {
+ return
std::make_shared<Aws::Auth::EnvironmentAWSCredentialsProvider>();
+ } else if (provider_upper == "INSTANCE_PROFILE" || provider_upper ==
"INSTANCEPROFILE") {
+ return
std::make_shared<Aws::Auth::InstanceProfileCredentialsProvider>();
+ } else if (provider_upper == "CONTAINER" || provider_upper == "ECS") {
+ return std::make_shared<Aws::Auth::TaskRoleCredentialsProvider>(
+
Aws::Environment::GetEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").c_str());
+ } else if (provider_upper == "SYSTEM_PROPERTIES" || provider_upper ==
"SYSTEMPROPERTIES") {
+ return
std::make_shared<Aws::Auth::ProfileConfigFileAWSCredentialsProvider>();
+ } else if (provider_upper == "WEB_IDENTITY" || provider_upper ==
"WEBIDENTITY" ||
+ provider_upper == "WEB_IDENTITY_TOKEN_FILE") {
+ return
std::make_shared<Aws::Auth::STSAssumeRoleWebIdentityCredentialsProvider>();
+ } else if (provider_upper == "ANONYMOUS") {
+ return std::make_shared<Aws::Auth::AnonymousAWSCredentialsProvider>();
+ } else if (provider_upper.empty() || provider_upper == "DEFAULT") {
+ return
std::make_shared<Aws::Auth::DefaultAWSCredentialsProviderChain>();
+ }
+
+ LOG(WARNING) << "Unknown credentials provider type: " << provider_type
+ << ", falling back to default credentials provider chain";
+ return std::make_shared<Aws::Auth::DefaultAWSCredentialsProviderChain>();
+}
+
+std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
+AwsMskIamAuth::_create_assume_role_base_provider() {
+ if (!_config.access_key.empty() && !_config.secret_key.empty()) {
+ Aws::Auth::AWSCredentials base_credentials(_config.access_key,
_config.secret_key);
+ return
std::make_shared<Aws::Auth::SimpleAWSCredentialsProvider>(base_credentials);
+ }
+
+ if (!_config.profile_name.empty()) {
+ return
std::make_shared<Aws::Auth::ProfileConfigFileAWSCredentialsProvider>(
+ _config.profile_name.c_str());
+ }
+
+ if (!_config.credentials_provider.empty()) {
+ return _create_provider_from_type(_config.credentials_provider);
+ }
+
+ return std::make_shared<Aws::Auth::DefaultAWSCredentialsProviderChain>();
+}
+
+std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
AwsMskIamAuth::_create_credentials_provider() {
+ if (!_config.role_arn.empty()) {
+ Aws::Client::ClientConfiguration client_config;
+ if (!_config.region.empty()) {
+ client_config.region = _config.region;
+ }
+
+ auto base_provider = _create_assume_role_base_provider();
+ LOG(INFO) << "Using AWS STS Assume Role: " << _config.role_arn;
+
+ auto sts_client = std::make_shared<Aws::STS::STSClient>(base_provider,
client_config);
+
+ Aws::String external_id = _config.external_id.empty()
+ ? Aws::String()
+ :
Aws::String(_config.external_id.c_str());
+ return std::make_shared<Aws::Auth::STSAssumeRoleCredentialsProvider>(
+ _config.role_arn, Aws::String(), external_id,
+ Aws::Auth::DEFAULT_CREDS_LOAD_FREQ_SECONDS, sts_client);
+ }
+ // 2. Explicit AK/SK credentials (direct access)
+ if (!_config.access_key.empty() && !_config.secret_key.empty()) {
+ LOG(INFO) << "Using explicit AWS credentials (Access Key ID: "
+ << _config.access_key.substr(0, 4) << "****)";
+
+ Aws::Auth::AWSCredentials credentials(_config.access_key,
_config.secret_key);
+
+ return
std::make_shared<Aws::Auth::SimpleAWSCredentialsProvider>(credentials);
+ }
+ // 3. AWS Profile (reads from ~/.aws/credentials)
+ if (!_config.profile_name.empty()) {
+ LOG(INFO) << "Using AWS Profile: " << _config.profile_name;
+
+ return
std::make_shared<Aws::Auth::ProfileConfigFileAWSCredentialsProvider>(
+ _config.profile_name.c_str());
+ }
+ // 4. Custom Credentials Provider
+ if (!_config.credentials_provider.empty()) {
+ LOG(INFO) << "Using custom credentials provider: " <<
_config.credentials_provider;
+ return _create_provider_from_type(_config.credentials_provider);
+ }
+ // No valid credentials configuration found
+ LOG(ERROR) << "AWS MSK IAM authentication requires credentials. Please
provide.";
+ return nullptr;
+}
+
+Status AwsMskIamAuth::get_credentials(Aws::Auth::AWSCredentials* credentials) {
+ std::lock_guard<std::mutex> lock(_mutex);
+
+ if (!_credentials_provider) {
+ return Status::InternalError("AWS credentials provider not
initialized");
+ }
+
+ // Refresh if needed
+ if (_should_refresh_credentials()) {
+ _cached_credentials = _credentials_provider->GetAWSCredentials();
+ if (_cached_credentials.GetAWSAccessKeyId().empty()) {
+ return Status::InternalError("Failed to get AWS credentials");
+ }
+
+ // Set expiry time (assume 1 hour for instance profile, or use the
credentials expiration)
+ _credentials_expiry = std::chrono::system_clock::now() +
std::chrono::hours(1);
+
+ LOG(INFO) << "Refreshed AWS credentials for MSK IAM authentication";
+ }
+
+ *credentials = _cached_credentials;
+ return Status::OK();
+}
+
+bool AwsMskIamAuth::_should_refresh_credentials() {
+ auto now = std::chrono::system_clock::now();
+ auto refresh_time =
+ _credentials_expiry -
std::chrono::milliseconds(_config.token_refresh_margin_ms);
+ return now >= refresh_time ||
_cached_credentials.GetAWSAccessKeyId().empty();
+}
+
+Status AwsMskIamAuth::generate_token(const std::string& broker_hostname,
std::string* token,
+ int64_t* token_lifetime_ms) {
+ Aws::Auth::AWSCredentials credentials;
+ RETURN_IF_ERROR(get_credentials(&credentials));
+
+ std::string timestamp = _get_timestamp();
+ std::string date_stamp = _get_date_stamp(timestamp);
+
+ // AWS MSK IAM token is a base64-encoded presigned URL
+ // Reference: https://github.com/aws/aws-msk-iam-sasl-signer-python
+
+ // Token expiry in seconds (900 seconds = 15 minutes, matching AWS MSK IAM
signer reference)
+ static constexpr int TOKEN_EXPIRY_SECONDS = 900;
+
+ // Build the endpoint URL
+ std::string endpoint_url = "https://kafka." + _config.region +
".amazonaws.com/";
+
+ // Build credential scope
+ std::string credential_scope =
+ date_stamp + "/" + _config.region + "/kafka-cluster/aws4_request";
+
+ // Build the canonical query string (sorted alphabetically)
+ // IMPORTANT: All query parameters must be included in the signature
calculation
+ // Session Token must be in canonical query string if using temporary
credentials
+ std::stringstream canonical_query_ss;
+ canonical_query_ss << "Action=kafka-cluster%3AConnect"; // URL-encoded :
+
+ // Add Algorithm
+ canonical_query_ss << "&X-Amz-Algorithm=AWS4-HMAC-SHA256";
+
+ // Add Credential
+ std::string credential = std::string(credentials.GetAWSAccessKeyId()) +
"/" + credential_scope;
+ canonical_query_ss << "&X-Amz-Credential=" << _url_encode(credential);
+
+ // Add Date
+ canonical_query_ss << "&X-Amz-Date=" << timestamp;
+
+ // Add Expires
+ canonical_query_ss << "&X-Amz-Expires=" << TOKEN_EXPIRY_SECONDS;
+
+ // Add Security Token if present (MUST be before signature calculation)
+ if (!credentials.GetSessionToken().empty()) {
+ canonical_query_ss << "&X-Amz-Security-Token="
+ <<
_url_encode(std::string(credentials.GetSessionToken()));
+ }
+
+ // Add SignedHeaders
+ canonical_query_ss << "&X-Amz-SignedHeaders=host";
+
+ std::string canonical_query_string = canonical_query_ss.str();
+
+ // Build the canonical headers
+ std::string host = "kafka." + _config.region + ".amazonaws.com";
+ std::string canonical_headers = "host:" + host + "\n";
+ std::string signed_headers = "host";
+
+ // Build the canonical request
+ std::string method = "GET";
+ std::string uri = "/";
+ std::string payload_hash = _sha256("");
+
+ std::string canonical_request = method + "\n" + uri + "\n" +
canonical_query_string + "\n" +
+ canonical_headers + "\n" + signed_headers
+ "\n" + payload_hash;
+
+ // Build the string to sign
+ std::string algorithm = "AWS4-HMAC-SHA256";
+ std::string canonical_request_hash = _sha256(canonical_request);
+ std::string string_to_sign =
+ algorithm + "\n" + timestamp + "\n" + credential_scope + "\n" +
canonical_request_hash;
+
+ // Calculate signature
+ std::string signing_key =
_calculate_signing_key(std::string(credentials.GetAWSSecretKey()),
+ date_stamp,
_config.region, "kafka-cluster");
+ std::string signature = _hmac_sha256_hex(signing_key, string_to_sign);
+
+ // Build the final presigned URL
+ // All parameters are already in canonical_query_string, just add signature
+ // Then add User-Agent AFTER signature (not part of signed content,
matching reference impl)
+ std::string signed_url = endpoint_url + "?" + canonical_query_string +
+ "&X-Amz-Signature=" + signature +
+ "&User-Agent=doris-msk-iam-auth%2F1.0";
+
+ // Base64url encode the signed URL (without padding)
+ *token = _base64url_encode(signed_url);
+
+ // Token lifetime in milliseconds
+ *token_lifetime_ms = TOKEN_EXPIRY_SECONDS * 1000;
+
+ VLOG_DEBUG << "Generated AWS MSK IAM token for region: " << _config.region;
+ return Status::OK();
+}
+
+std::string AwsMskIamAuth::_hmac_sha256_hex(const std::string& key, const
std::string& data) {
+ std::string raw = _hmac_sha256(key, data);
+ std::stringstream ss;
+ for (unsigned char c : raw) {
+ ss << std::hex << std::setw(2) << std::setfill('0') <<
static_cast<int>(c);
+ }
+ return ss.str();
+}
+
+std::string AwsMskIamAuth::_url_encode(const std::string& value) {
+ std::ostringstream escaped;
+ escaped.fill('0');
+ escaped << std::hex;
+
+ for (char c : value) {
+ // Keep alphanumeric and other accepted characters intact
+ if (isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' ||
c == '.' ||
+ c == '~') {
+ escaped << c;
+ } else {
+ // Any other characters are percent-encoded
+ escaped << std::uppercase;
+ escaped << '%' << std::setw(2) <<
static_cast<int>(static_cast<unsigned char>(c));
+ escaped << std::nouppercase;
+ }
+ }
+
+ return escaped.str();
+}
+
+std::string AwsMskIamAuth::_base64url_encode(const std::string& input) {
+ // Standard base64 alphabet
+ static const char* base64_chars =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+ std::string result;
+ result.reserve(((input.size() + 2) / 3) * 4);
+
+ const unsigned char* bytes = reinterpret_cast<const unsigned
char*>(input.c_str());
+ size_t len = input.size();
+
+ for (size_t i = 0; i < len; i += 3) {
+ uint32_t n = static_cast<uint32_t>(bytes[i]) << 16;
+ if (i + 1 < len) n |= static_cast<uint32_t>(bytes[i + 1]) << 8;
+ if (i + 2 < len) n |= static_cast<uint32_t>(bytes[i + 2]);
+
+ result += base64_chars[(n >> 18) & 0x3F];
+ result += base64_chars[(n >> 12) & 0x3F];
+ if (i + 1 < len) result += base64_chars[(n >> 6) & 0x3F];
+ if (i + 2 < len) result += base64_chars[n & 0x3F];
+ }
+
+ // Convert to URL-safe base64 (replace + with -, / with _)
+ // and remove padding (=)
+ for (char& c : result) {
+ if (c == '+')
+ c = '-';
+ else if (c == '/')
+ c = '_';
+ }
+
+ return result;
+}
+
+std::string AwsMskIamAuth::_calculate_signing_key(const std::string&
secret_key,
+ const std::string&
date_stamp,
+ const std::string& region,
+ const std::string& service) {
+ std::string k_secret = "AWS4" + secret_key;
+ std::string k_date = _hmac_sha256(k_secret, date_stamp);
+ std::string k_region = _hmac_sha256(k_date, region);
+ std::string k_service = _hmac_sha256(k_region, service);
+ std::string k_signing = _hmac_sha256(k_service, "aws4_request");
+ return k_signing;
+}
+
+std::string AwsMskIamAuth::_hmac_sha256(const std::string& key, const
std::string& data) {
+ unsigned char digest[EVP_MAX_MD_SIZE];
+ unsigned int digest_len = 0;
+ HMAC(EVP_sha256(), key.c_str(), static_cast<int>(key.length()),
+ reinterpret_cast<const unsigned char*>(data.c_str()), data.length(),
digest, &digest_len);
+ return {reinterpret_cast<char*>(digest), digest_len};
+}
+
+std::string AwsMskIamAuth::_sha256(const std::string& data) {
+ unsigned char hash[SHA256_DIGEST_LENGTH];
+ SHA256(reinterpret_cast<const unsigned char*>(data.c_str()),
data.length(), hash);
+
+ std::stringstream ss;
+ for (unsigned char i : hash) {
+ ss << std::hex << std::setw(2) << std::setfill('0') << (int)i;
+ }
+ return ss.str();
+}
+
+std::string AwsMskIamAuth::_get_timestamp() {
+ auto now = std::chrono::system_clock::now();
+ auto time_t_now = std::chrono::system_clock::to_time_t(now);
+ std::tm tm_now;
+ gmtime_r(&time_t_now, &tm_now);
+
+ std::stringstream ss;
+ ss << std::put_time(&tm_now, "%Y%m%dT%H%M%SZ");
+ return ss.str();
+}
+
+std::string AwsMskIamAuth::_get_date_stamp(const std::string& timestamp) {
+ // Extract YYYYMMDD from YYYYMMDDTHHMMSSz
+ return timestamp.substr(0, 8);
+}
+
+// AwsMskIamOAuthCallback implementation
+
+namespace {
+// Property keys for AWS MSK IAM authentication
+constexpr const char* PROP_SECURITY_PROTOCOL = "security.protocol";
+constexpr const char* PROP_SASL_MECHANISM = "sasl.mechanism";
+constexpr const char* PROP_AWS_REGION = "aws.region";
+constexpr const char* PROP_AWS_ACCESS_KEY = "aws.access_key";
+constexpr const char* PROP_AWS_SECRET_KEY = "aws.secret_key";
+constexpr const char* PROP_AWS_ROLE_ARN = "aws.role_arn";
+constexpr const char* PROP_AWS_EXTERNAL_ID = "aws.external_id";
+constexpr const char* PROP_AWS_PROFILE_NAME = "aws.profile_name";
+constexpr const char* PROP_AWS_CREDENTIALS_PROVIDER =
"aws.credentials_provider";
+} // namespace
+
+std::unique_ptr<AwsMskIamOAuthCallback>
AwsMskIamOAuthCallback::create_from_properties(
+ const std::unordered_map<std::string, std::string>& custom_properties,
+ const std::string& brokers) {
+ auto security_protocol_it = custom_properties.find(PROP_SECURITY_PROTOCOL);
+ auto sasl_mechanism_it = custom_properties.find(PROP_SASL_MECHANISM);
+ bool is_sasl_ssl = security_protocol_it != custom_properties.end() &&
+ security_protocol_it->second == "SASL_SSL";
+ bool is_oauthbearer = sasl_mechanism_it != custom_properties.end() &&
+ sasl_mechanism_it->second == "OAUTHBEARER";
+
+ if (!is_sasl_ssl || !is_oauthbearer) {
+ return nullptr;
+ }
+
+ // Extract broker hostname for token generation.
+ std::string broker_hostname = brokers;
+ // If there are multiple brokers, we use the first one (Refrain : is this
ok?)
+ if (broker_hostname.find(',') != std::string::npos) {
+ broker_hostname = broker_hostname.substr(0, broker_hostname.find(','));
+ }
+ // Remove port if present
+ if (broker_hostname.find(':') != std::string::npos) {
+ broker_hostname = broker_hostname.substr(0, broker_hostname.find(':'));
+ }
+
+ AwsMskIamAuth::Config auth_config;
+
+ auto region_it = custom_properties.find(PROP_AWS_REGION);
+ if (region_it != custom_properties.end()) {
+ auth_config.region = region_it->second;
+ }
+
+ auto access_key_it = custom_properties.find(PROP_AWS_ACCESS_KEY);
+ auto secret_key_it = custom_properties.find(PROP_AWS_SECRET_KEY);
+ if (access_key_it != custom_properties.end() && secret_key_it !=
custom_properties.end()) {
+ auth_config.access_key = access_key_it->second;
+ auth_config.secret_key = secret_key_it->second;
+ LOG(INFO) << "AWS MSK IAM: using explicit credentials (region: " <<
auth_config.region
+ << ")";
+ }
+
+ auto role_arn_it = custom_properties.find(PROP_AWS_ROLE_ARN);
+ if (role_arn_it != custom_properties.end()) {
+ auth_config.role_arn = role_arn_it->second;
+ LOG(INFO) << "AWS MSK IAM: using role " << auth_config.role_arn
+ << " (region: " << auth_config.region << ")";
+ }
+
+ auto external_id_it = custom_properties.find(PROP_AWS_EXTERNAL_ID);
+ if (external_id_it != custom_properties.end()) {
+ auth_config.external_id = external_id_it->second;
+ LOG(INFO) << "AWS MSK IAM: using external id with role assumption
(region: "
+ << auth_config.region << ")";
+ }
+
+ auto profile_name_it = custom_properties.find(PROP_AWS_PROFILE_NAME);
+ if (profile_name_it != custom_properties.end()) {
+ auth_config.profile_name = profile_name_it->second;
+ LOG(INFO) << "AWS MSK IAM: using profile " << auth_config.profile_name
+ << " (region: " << auth_config.region << ")";
+ }
+
+ auto credentials_provider_it =
custom_properties.find(PROP_AWS_CREDENTIALS_PROVIDER);
+ if (credentials_provider_it != custom_properties.end()) {
+ auth_config.credentials_provider = credentials_provider_it->second;
+ LOG(INFO) << "AWS MSK IAM: using credentials provider " <<
auth_config.credentials_provider
+ << " (region: " << auth_config.region << ")";
+ }
+
+ if (!auth_config.external_id.empty() && auth_config.role_arn.empty()) {
+ LOG(ERROR) << "AWS MSK IAM authentication: 'aws.external_id' requires
'aws.role_arn'";
+ return nullptr;
+ }
+
+ // Validate that at least one credential source is configured
+ bool has_credentials = !auth_config.access_key.empty() ||
!auth_config.role_arn.empty() ||
+ !auth_config.profile_name.empty() ||
+ !auth_config.credentials_provider.empty();
+
+ if (!has_credentials) {
+ LOG(ERROR) << "AWS MSK IAM authentication enabled but no credentials
configured. "
+ << "Please provide one of: access_key/secret_key, role_arn,
profile_name, or "
+ "credentials_provider";
+ return nullptr;
+ }
+
+ LOG(INFO) << "Enabling AWS MSK IAM authentication for broker: " <<
broker_hostname
+ << ", region: " << auth_config.region;
+
+ auto auth = std::make_shared<AwsMskIamAuth>(auth_config);
+ return std::make_unique<AwsMskIamOAuthCallback>(std::move(auth),
std::move(broker_hostname));
+}
+
+AwsMskIamOAuthCallback::AwsMskIamOAuthCallback(std::shared_ptr<AwsMskIamAuth>
auth,
+ std::string broker_hostname)
+ : _auth(std::move(auth)), _broker_hostname(std::move(broker_hostname))
{}
+
+Status AwsMskIamOAuthCallback::refresh_now(RdKafka::Handle* handle) {
+ std::string token;
+ int64_t token_lifetime_ms = 0;
+
+ RETURN_IF_ERROR(_auth->generate_token(_broker_hostname, &token,
&token_lifetime_ms));
+
+ std::string principal = "doris-consumer";
+ std::list<std::string> extensions;
+ std::string errstr;
+
+ auto now = std::chrono::system_clock::now();
+ auto now_ms =
+
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count();
+ int64_t token_expiry_ms = now_ms + token_lifetime_ms;
+
+ auto err = handle->oauthbearer_set_token(token, token_expiry_ms,
principal, extensions, errstr);
+ if (err != RdKafka::ERR_NO_ERROR) {
+ return Status::InternalError("Failed to set OAuth token: {}, detail:
{}",
+ RdKafka::err2str(err), errstr);
+ }
+
+ LOG(INFO) << "Successfully set AWS MSK IAM OAuth token, lifetime: " <<
token_lifetime_ms
+ << "ms";
+ return Status::OK();
+}
+
+void AwsMskIamOAuthCallback::oauthbearer_token_refresh_cb(
+ RdKafka::Handle* handle, const std::string& /*oauthbearer_config*/) {
+ Status st = refresh_now(handle);
+ if (!st.ok()) {
+ LOG(WARNING) << st;
+ handle->oauthbearer_set_token_failure(st.to_string());
+ }
+}
+
+} // namespace doris
diff --git a/be/src/runtime/aws_msk_iam_auth.h
b/be/src/runtime/aws_msk_iam_auth.h
new file mode 100644
index 00000000000..79ceb03dd77
--- /dev/null
+++ b/be/src/runtime/aws_msk_iam_auth.h
@@ -0,0 +1,169 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+#pragma once
+
+#include <aws/core/auth/AWSCredentialsProvider.h>
+#include <librdkafka/rdkafkacpp.h>
+
+#include <memory>
+#include <mutex>
+#include <string>
+#include <unordered_map>
+
+#include "common/status.h"
+
+namespace doris {
+/**
+ * AWS MSK IAM authentication token generator.
+ *
+ * This class generates SASL/OAUTHBEARER tokens for AWS MSK IAM authentication.
+ * It uses AWS SDK for C++ to obtain IAM credentials and generates signed
tokens
+ * that can be used with librdkafka's OAUTHBEARER mechanism.
+ */
+class AwsMskIamAuth {
+public:
+ struct Config {
+ std::string region; // AWS region (e.g., "us-east-1"), required
+ std::string access_key; // AWS Access Key ID (optional, for explicit
credentials)
+ std::string secret_key; // AWS Secret Access Key (optional, for
explicit credentials)
+ std::string role_arn; // IAM role ARN (optional, for assume role)
+ std::string external_id; // Optional external ID for STS AssumeRole
+ std::string profile_name; // AWS profile name (optional, reads from
~/.aws/credentials)
+ std::string
+ credentials_provider; // Credentials provider type (optional,
e.g., "ENV", "INSTANCE_PROFILE")
+ int token_refresh_margin_ms = 60000; // Refresh token 60s before expiry
+ };
+
+ explicit AwsMskIamAuth(Config config);
+ ~AwsMskIamAuth() = default;
+
+ /**
+ * Generate AWS MSK IAM authentication token.
+ *
+ * The token is a base64url-encoded presigned URL following AWS SigV4
format:
+ * https://kafka.<region>.amazonaws.com/?Action=kafka-cluster:Connect
+ * &X-Amz-Algorithm=AWS4-HMAC-SHA256
+ *
&X-Amz-Credential=<access-key>/<date>/<region>/kafka-cluster/aws4_request
+ * &X-Amz-Date=<timestamp>
+ * &X-Amz-Expires=900
+ * &X-Amz-SignedHeaders=host
+ * &X-Amz-Signature=<signature>
+ * &X-Amz-Security-Token=<session-token> // if using temporary
credentials
+ * &User-Agent=doris-msk-iam-auth/1.0
+ *
+ * Reference: https://github.com/aws/aws-msk-iam-sasl-signer-python
+ *
+ * @param broker_hostname The MSK broker hostname (used for logging, not
in token)
+ * @param token Output: base64url-encoded signed URL token
+ * @param token_lifetime_ms Output: token lifetime in milliseconds
(3600000ms = 1 hour)
+ * @return Status indicating success or failure
+ */
+ Status generate_token(const std::string& broker_hostname, std::string*
token,
+ int64_t* token_lifetime_ms);
+
+ /**
+ * Get current AWS credentials.
+ * This will refresh credentials if they are expired or about to expire.
+ */
+ Status get_credentials(Aws::Auth::AWSCredentials* credentials);
+
+private:
+ // Create AWS credentials provider based on configuration
+ std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
_create_credentials_provider();
+ std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
_create_provider_from_type(
+ const std::string& provider_type);
+ std::shared_ptr<Aws::Auth::AWSCredentialsProvider>
_create_assume_role_base_provider();
+
+ // HMAC-SHA256 returning hex string
+ std::string _hmac_sha256_hex(const std::string& key, const std::string&
data);
+
+ std::string _url_encode(const std::string& value);
+
+ std::string _base64url_encode(const std::string& input);
+
+ // Calculate AWS SigV4 signing key
+ std::string _calculate_signing_key(const std::string& secret_key, const
std::string& date_stamp,
+ const std::string& region, const
std::string& service);
+
+ std::string _hmac_sha256(const std::string& key, const std::string& data);
+
+ std::string _sha256(const std::string& data);
+
+ std::string _get_timestamp();
+
+ std::string _get_date_stamp(const std::string& timestamp);
+
+ bool _should_refresh_credentials();
+
+ Config _config;
+ std::shared_ptr<Aws::Auth::AWSCredentialsProvider> _credentials_provider;
+ std::mutex _mutex;
+ Aws::Auth::AWSCredentials _cached_credentials;
+ std::chrono::time_point<std::chrono::system_clock> _credentials_expiry;
+};
+
+/**
+ * librdkafka OAUTHBEARER callback for AWS MSK IAM authentication.
+ *
+ * This callback is invoked by librdkafka when it needs to refresh the
+ * OAUTHBEARER token. It uses AwsMskIamAuth to generate the token.
+ */
+class AwsMskIamOAuthCallback : public RdKafka::OAuthBearerTokenRefreshCb {
+public:
+ /**
+ * Create an OAuth callback from Kafka custom properties.
+ *
+ * This factory method checks if AWS MSK IAM authentication is configured
+ * (security.protocol=SASL_SSL and sasl.mechanism=OAUTHBEARER) and creates
+ * the callback with proper configuration.
+ *
+ * @param custom_properties Kafka custom properties map
+ * @param brokers Kafka broker list (comma-separated)
+ * @return unique_ptr to callback if IAM auth is configured, nullptr
otherwise
+ */
+ static std::unique_ptr<AwsMskIamOAuthCallback> create_from_properties(
+ const std::unordered_map<std::string, std::string>&
custom_properties,
+ const std::string& brokers);
+
+ explicit AwsMskIamOAuthCallback(std::shared_ptr<AwsMskIamAuth> auth,
+ std::string broker_hostname);
+
+ /**
+ * Synchronously refresh and set OAuth token.
+ * Can be called directly during initialization or by the callback.
+ *
+ * @param handle The Kafka handle (consumer or producer)
+ * @return Status indicating success or failure
+ */
+ Status refresh_now(RdKafka::Handle* handle);
+
+ /**
+ * Callback invoked by librdkafka to refresh OAuth token.
+ *
+ * @param handle The Kafka handle (consumer or producer)
+ * @param oauthbearer_config Configuration string from
'sasl.oauthbearer.config'
+ */
+ void oauthbearer_token_refresh_cb(RdKafka::Handle* handle,
+ const std::string& oauthbearer_config)
override;
+
+private:
+ std::shared_ptr<AwsMskIamAuth> _auth;
+ std::string _broker_hostname;
+};
+
+} // namespace doris
diff --git a/be/test/runtime/aws_msk_iam_auth_test.cpp
b/be/test/runtime/aws_msk_iam_auth_test.cpp
new file mode 100644
index 00000000000..f1eb1ec37ed
--- /dev/null
+++ b/be/test/runtime/aws_msk_iam_auth_test.cpp
@@ -0,0 +1,244 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+#include "runtime/aws_msk_iam_auth.h"
+
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <string>
+#include <unordered_map>
+
+#include "common/status.h"
+
+namespace doris {
+
+class AwsMskIamAuthTest : public testing::Test {
+protected:
+ void SetUp() override {
+ // Setup test configuration
+ config.region = "us-east-1";
+ }
+
+ AwsMskIamAuth::Config config;
+};
+
+TEST_F(AwsMskIamAuthTest, TestConfigCreation) {
+ // Test basic configuration creation
+ AwsMskIamAuth auth(config);
+
+ // This test just ensures the object can be created without crashing
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestTokenGeneration) {
+ // This test requires AWS credentials to be available
+ // In a real environment, you would mock the AWS SDK or use test
credentials
+
+ AwsMskIamAuth auth(config);
+ std::string token;
+ int64_t token_lifetime_ms;
+
+ std::string broker_hostname = "b-1.test-msk.us-east-1.amazonaws.com";
+
+ // In a real test environment with credentials, this should succeed
+ // For CI/CD without credentials, we expect it to fail gracefully
+ Status status = auth.generate_token(broker_hostname, &token,
&token_lifetime_ms);
+
+ if (status.ok()) {
+ // If we have credentials, verify token properties
+ ASSERT_FALSE(token.empty());
+ ASSERT_GT(token_lifetime_ms, 0);
+ ASSERT_LT(token_lifetime_ms, 3600000); // Less than 1 hour
+
+ // Token should be valid JSON
+ ASSERT_NE(token.find("version"), std::string::npos);
+ ASSERT_NE(token.find("host"), std::string::npos);
+ ASSERT_NE(token.find(broker_hostname), std::string::npos);
+ } else {
+ // Without credentials, we expect an error but no crash
+ ASSERT_FALSE(status.ok());
+ LOG(INFO) << "Token generation failed (expected without AWS
credentials): "
+ << status.to_string();
+ }
+}
+
+TEST_F(AwsMskIamAuthTest, TestConfigWithRoleArn) {
+ config.role_arn = "arn:aws:iam::123456789012:role/TestRole";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error even if role doesn't exist
+ // (actual assumption will fail later when trying to get credentials)
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestConfigWithRoleArnAndExternalId) {
+ config.role_arn = "arn:aws:iam::123456789012:role/TestRole";
+ config.external_id = "external-id-123";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error even if role doesn't exist
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestConfigWithProfile) {
+ config.profile_name = "test-profile";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error even if profile doesn't exist
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestConfigWithCredentialsProvider) {
+ config.credentials_provider = "INSTANCE_PROFILE";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestConfigWithExplicitCredentials) {
+ config.access_key = "AKIAIOSFODNN7EXAMPLE";
+ config.secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestCrossAccountAssumeRole) {
+ config.role_arn = "arn:aws:iam::123456789012:role/CrossAccountRole";
+ config.access_key = "AKIAIOSFODNN7EXAMPLE";
+ config.secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error (actual assume role will fail without real
credentials)
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestRoleArnWithCredentialsProvider) {
+ config.role_arn = "arn:aws:iam::123456789012:role/TestRole";
+ config.credentials_provider = "ENV";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error. ENV provider will be used as AssumeRole
base credentials provider.
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestRoleArnWithProfileName) {
+ config.role_arn = "arn:aws:iam::123456789012:role/TestRole";
+ config.profile_name = "default";
+
+ AwsMskIamAuth auth(config);
+
+ // Should create without error. Profile provider will be used as
AssumeRole base credentials provider.
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestMultipleRegions) {
+ std::vector<std::string> regions = {"us-east-1", "us-west-2", "eu-west-1",
"ap-southeast-1"};
+
+ for (const auto& region : regions) {
+ config.region = region;
+ AwsMskIamAuth auth(config);
+
+ std::string broker_hostname = "b-1.test-msk." + region +
".amazonaws.com";
+ std::string token;
+ int64_t token_lifetime_ms;
+
+ Status status = auth.generate_token(broker_hostname, &token,
&token_lifetime_ms);
+
+ // We don't expect this to succeed without credentials,
+ // but it should fail gracefully without crashing
+ if (!status.ok()) {
+ LOG(INFO) << "Token generation for region " << region
+ << " failed (expected): " << status.to_string();
+ }
+ }
+}
+
+TEST_F(AwsMskIamAuthTest, TestOAuthCallbackCreation) {
+ auto auth = std::make_shared<AwsMskIamAuth>(config);
+ std::string broker_hostname = "b-1.test-msk.us-east-1.amazonaws.com";
+
+ AwsMskIamOAuthCallback callback(auth, broker_hostname);
+
+ // Callback should be created successfully
+ ASSERT_TRUE(true);
+}
+
+TEST_F(AwsMskIamAuthTest, TestOAuthCallbackCreationWithExternalIdAndRoleArn) {
+ std::unordered_map<std::string, std::string> properties {
+ {"security.protocol", "SASL_SSL"},
+ {"sasl.mechanism", "OAUTHBEARER"},
+ {"aws.region", "us-east-1"},
+ {"aws.role_arn", "arn:aws:iam::123456789012:role/TestRole"},
+ {"aws.external_id", "external-id-123"},
+ };
+
+ auto callback = AwsMskIamOAuthCallback::create_from_properties(
+ properties, "b-1.test-msk.us-east-1.amazonaws.com:9098");
+ ASSERT_NE(callback, nullptr);
+}
+
+TEST_F(AwsMskIamAuthTest,
TestOAuthCallbackCreationWithExternalIdWithoutRoleArn) {
+ std::unordered_map<std::string, std::string> properties {
+ {"security.protocol", "SASL_SSL"},
+ {"sasl.mechanism", "OAUTHBEARER"},
+ {"aws.region", "us-east-1"},
+ {"aws.external_id", "external-id-123"},
+ };
+
+ auto callback = AwsMskIamOAuthCallback::create_from_properties(
+ properties, "b-1.test-msk.us-east-1.amazonaws.com:9098");
+ ASSERT_EQ(callback, nullptr);
+}
+
+// Integration test - only runs if AWS credentials are available
+TEST_F(AwsMskIamAuthTest, DISABLED_IntegrationTestWithRealCredentials) {
+ // This test is disabled by default
+ // To run it, you need:
+ // 1. AWS credentials configured (environment variables,
~/.aws/credentials, or IAM role)
+ // 2. Run with: --gtest_also_run_disabled_tests
+
+ config.credentials_provider = "INSTANCE_PROFILE";
+
+ AwsMskIamAuth auth(config);
+ std::string token;
+ int64_t token_lifetime_ms;
+
+ std::string broker_hostname =
"b-1.real-msk-cluster.us-east-1.amazonaws.com";
+
+ Status status = auth.generate_token(broker_hostname, &token,
&token_lifetime_ms);
+
+ ASSERT_TRUE(status.ok()) << "Token generation failed: " <<
status.to_string();
+ ASSERT_FALSE(token.empty());
+ ASSERT_GT(token_lifetime_ms, 0);
+
+ LOG(INFO) << "Generated token (first 100 chars): "
+ << token.substr(0, std::min(size_t(100), token.size()));
+ LOG(INFO) << "Token lifetime: " << token_lifetime_ms << "ms";
+}
+
+} // namespace doris
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaConfiguration.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaConfiguration.java
index 959d88bb5da..259bb797b6d 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaConfiguration.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaConfiguration.java
@@ -46,6 +46,21 @@ public enum KafkaConfiguration {
KAFKA_TEXT_TABLE_NAME_FIELD_DELIMITER("kafka_text_table_name_field_delimiter",
",",
value -> value.replace(" ", ""));
+
+ // Common Kafka property names used for security and authentication
+ // These are passed through property.* prefix and validated for correctness
+ public static final String SECURITY_PROTOCOL = "security.protocol";
+ public static final String SASL_MECHANISM = "sasl.mechanism";
+ public static final String SASL_JAAS_CONFIG = "sasl.jaas.config";
+
+ // AWS MSK IAM authentication properties
+ public static final String AWS_REGION = "aws.region";
+ public static final String AWS_ACCESS_KEY = "aws.access_key";
+ public static final String AWS_SECRET_KEY = "aws.secret_key";
+ public static final String AWS_MSK_IAM_ROLE_ARN = "aws.role_arn";
+ public static final String AWS_EXTERNAL_ID = "aws.external_id";
+ public static final String AWS_PROFILE_NAME = "aws.profile_name";
+ public static final String AWS_CREDENTIALS_PROVIDER =
"aws.credentials_provider";
private final String name;
public String getName() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaDataSourceProperties.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaDataSourceProperties.java
index 47c4c3b19be..a5b1afb2cd3 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaDataSourceProperties.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/kafka/KafkaDataSourceProperties.java
@@ -34,6 +34,8 @@ import lombok.Setter;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import java.util.Arrays;
import java.util.HashMap;
@@ -47,10 +49,12 @@ import java.util.regex.Pattern;
* Kafka data source properties
*/
public class KafkaDataSourceProperties extends AbstractDataSourceProperties {
+ private static final Logger LOG =
LogManager.getLogger(KafkaDataSourceProperties.class);
private static final String ENDPOINT_REGEX =
"[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]";
private static final String CUSTOM_KAFKA_PROPERTY_PREFIX = "property.";
+ private static final String AWS_PROPERTY_PREFIX = "aws.";
@Getter
@Setter
@@ -124,7 +128,8 @@ public class KafkaDataSourceProperties extends
AbstractDataSourceProperties {
public void convertAndCheckDataSourceProperties() throws UserException {
Optional<String> optional = originalDataSourceProperties.keySet()
.stream().filter(entity ->
!CONFIGURABLE_DATA_SOURCE_PROPERTIES_SET.contains(entity))
- .filter(entity ->
!entity.startsWith(CUSTOM_KAFKA_PROPERTY_PREFIX)).findFirst();
+ .filter(entity ->
!entity.startsWith(CUSTOM_KAFKA_PROPERTY_PREFIX))
+ .filter(entity ->
!entity.startsWith(AWS_PROPERTY_PREFIX)).findFirst();
if (optional.isPresent()) {
throw new AnalysisException(optional.get() + " is invalid kafka
property or can not be set");
}
@@ -233,16 +238,81 @@ public class KafkaDataSourceProperties extends
AbstractDataSourceProperties {
private void analyzeCustomProperties() throws AnalysisException {
this.customKafkaProperties = new HashMap<>();
for (Map.Entry<String, String> dataSourceProperty :
originalDataSourceProperties.entrySet()) {
- if
(dataSourceProperty.getKey().startsWith(CUSTOM_KAFKA_PROPERTY_PREFIX)) {
- String propertyKey = dataSourceProperty.getKey();
- String propertyValue = dataSourceProperty.getValue();
+ String propertyKey = dataSourceProperty.getKey();
+ String propertyValue = dataSourceProperty.getValue();
+ if (propertyKey.startsWith(CUSTOM_KAFKA_PROPERTY_PREFIX)) {
+ // property.xxx -> xxx (strip "property." prefix)
String[] propertyValueArr = propertyKey.split("\\.");
if (propertyValueArr.length < 2) {
throw new AnalysisException("kafka property value could
not be a empty string");
}
this.customKafkaProperties.put(propertyKey.substring(propertyKey.indexOf(".") +
1), propertyValue);
+ } else if (propertyKey.startsWith(AWS_PROPERTY_PREFIX)) {
+ // aws.xxx -> aws.xxx (keep as-is, no prefix stripping)
+ // AWS properties are Doris-specific and passed directly to BE
+ this.customKafkaProperties.put(propertyKey, propertyValue);
+ }
+ }
+
+ // Validate AWS MSK IAM authentication configuration if present
+ validateAwsMskIamConfig();
+ }
+
+ private void validateAwsMskIamConfig() throws AnalysisException {
+ String securityProtocol =
customKafkaProperties.get(KafkaConfiguration.SECURITY_PROTOCOL);
+ String saslMechanism =
customKafkaProperties.get(KafkaConfiguration.SASL_MECHANISM);
+
+ // Check if AWS MSK IAM authentication is being used
+ boolean hasAwsRegion =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_REGION);
+ boolean hasAccessKey =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_ACCESS_KEY);
+ boolean hasSecretKey =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_SECRET_KEY);
+ boolean hasRoleArn =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_MSK_IAM_ROLE_ARN);
+ boolean hasExternalId =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_EXTERNAL_ID);
+ boolean hasProfileName =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_PROFILE_NAME);
+ boolean hasCredentialsProvider =
customKafkaProperties.containsKey(KafkaConfiguration.AWS_CREDENTIALS_PROVIDER);
+
+ boolean isAwsMskIam = hasAwsRegion || hasAccessKey || hasSecretKey ||
hasRoleArn
+ || hasExternalId
+ || hasProfileName || hasCredentialsProvider;
+
+ // If AWS-related property is set, validate the complete configuration
+ if (isAwsMskIam) {
+ if (hasExternalId && !hasRoleArn) {
+ throw new AnalysisException("When using AWS MSK IAM
authentication, "
+ + "'aws.external_id' must be used with
'aws.role_arn'.");
+ }
+
+ // aws.region is required for AWS MSK IAM authentication
+ if (!hasAwsRegion) {
+ throw new AnalysisException("When using AWS MSK IAM
authentication, "
+ + "'aws.region' is required. Please specify the AWS
region.");
+ }
+
+ // security.protocol should be SASL_SSL for AWS MSK IAM
+ if (securityProtocol == null ||
!"SASL_SSL".equalsIgnoreCase(securityProtocol)) {
+ throw new AnalysisException("For AWS MSK IAM authentication, "
+ + "'property.security.protocol' must be 'SASL_SSL' "
+ + "(SSL encryption + SASL authentication required), "
+ + "but got: " + securityProtocol);
+ }
+
+ // sasl.mechanism should be OAUTHBEARER for AWS MSK IAM
+ if (saslMechanism == null ||
!"OAUTHBEARER".equalsIgnoreCase(saslMechanism)) {
+ throw new AnalysisException("For AWS MSK IAM authentication, "
+ + "'property.sasl.mechanism' must be 'OAUTHBEARER',
but got: " + saslMechanism + ". "
+ + "Other SASL mechanisms are not supported now.");
+ }
+
+ // Detect if this is public (internet) access or internal AWS
access
+ boolean isPublicAccess = brokerList != null &&
brokerList.contains("-public.");
+
+ if (isPublicAccess) {
+ if (!(hasAccessKey && hasSecretKey)) {
+ throw new AnalysisException(
+ "When using explicit AWS credentials for public
(internet) access, "
+ + "both 'aws.access_key' and 'aws.secret_key' must
be provided together.");
+ }
}
- // can be extended in the future which other prefix
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/load/routineload/KafkaAwsMskIamAuthTest.java
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/KafkaAwsMskIamAuthTest.java
new file mode 100644
index 00000000000..ed77128d02f
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/KafkaAwsMskIamAuthTest.java
@@ -0,0 +1,491 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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 org.apache.doris.load.routineload;
+
+import org.apache.doris.common.UserException;
+import org.apache.doris.load.routineload.kafka.KafkaDataSourceProperties;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Test cases for AWS MSK IAM authentication configuration in Routine Load.
+ */
+public class KafkaAwsMskIamAuthTest {
+
+ private Map<String, String> dataSourceProperties;
+
+ @Before
+ public void setUp() {
+ dataSourceProperties = new HashMap<>();
+ dataSourceProperties.put("kafka_broker_list",
"b-1.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9098");
+ dataSourceProperties.put("kafka_topic", "test_topic");
+ }
+
+ @Test
+ public void testValidAwsMskIamConfig() throws UserException {
+ // Test with OAUTHBEARER mechanism (correct way for AWS MSK IAM)
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyMskRole");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("SASL_SSL",
props.getCustomKafkaProperties().get("security.protocol"));
+ Assert.assertEquals("OAUTHBEARER",
props.getCustomKafkaProperties().get("sasl.mechanism"));
+ Assert.assertEquals("us-east-1",
props.getCustomKafkaProperties().get("aws.region"));
+ }
+
+ @Test
+ public void testValidOAuthBearerConfig() throws UserException {
+ // Test with OAUTHBEARER mechanism (alternative approach)
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("SASL_SSL",
props.getCustomKafkaProperties().get("security.protocol"));
+ Assert.assertEquals("OAUTHBEARER",
props.getCustomKafkaProperties().get("sasl.mechanism"));
+ }
+
+ @Test
+ public void testMissingSecurityProtocol() {
+ // Test missing security.protocol when using AWS MSK IAM
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for missing
security.protocol");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("security.protocol"));
+ Assert.assertTrue(e.getMessage().contains("SASL_SSL"));
+ }
+ }
+
+ @Test
+ public void testInvalidSecurityProtocol() {
+ // Test invalid security.protocol value
+ dataSourceProperties.put("property.security.protocol", "PLAINTEXT");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for invalid
security.protocol");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("SASL_SSL"));
+ }
+ }
+
+ @Test
+ public void testMissingSaslMechanism() {
+ // Test missing sasl.mechanism when using SASL_SSL with AWS
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for missing
sasl.mechanism");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("sasl.mechanism"));
+ Assert.assertTrue(e.getMessage().contains("OAUTHBEARER"));
+ }
+ }
+
+ @Test
+ public void testInvalidSaslMechanism() {
+ // Test invalid sasl.mechanism value when using AWS MSK IAM
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "PLAIN");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for invalid
sasl.mechanism with AWS config");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("OAUTHBEARER"));
+ }
+ }
+
+ @Test
+ public void testMissingRegionWithRoleArn() {
+ // Test with role ARN but missing region - should fail
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyMskRole");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for missing region
with AWS properties");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.region"));
+ Assert.assertTrue(e.getMessage().contains("required"));
+ }
+ }
+
+ @Test
+ public void testCompleteAwsIamConfigWithRoleArn() throws UserException {
+ // Test complete configuration with IAM role ARN
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyMskRole");
+ dataSourceProperties.put("property.aws.profile_name", "default");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Map<String, String> customProps = props.getCustomKafkaProperties();
+ Assert.assertEquals("SASL_SSL", customProps.get("security.protocol"));
+ Assert.assertEquals("OAUTHBEARER", customProps.get("sasl.mechanism"));
+ Assert.assertEquals("us-east-1", customProps.get("aws.region"));
+ Assert.assertEquals("arn:aws:iam::123456789012:role/MyMskRole",
customProps.get("aws.role_arn"));
+ Assert.assertEquals("default", customProps.get("aws.profile_name"));
+ }
+
+ @Test
+ public void testExternalIdWithRoleArn() throws UserException {
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyMskRole");
+ dataSourceProperties.put("property.aws.external_id",
"external-id-123");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Map<String, String> customProps = props.getCustomKafkaProperties();
+ Assert.assertEquals("external-id-123",
customProps.get("aws.external_id"));
+ }
+
+ @Test
+ public void testExternalIdWithoutRoleArn() {
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.external_id",
"external-id-123");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for external_id
without role_arn");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.external_id"));
+ Assert.assertTrue(e.getMessage().contains("aws.role_arn"));
+ }
+ }
+
+ @Test
+ public void testScramSha256Config() throws UserException {
+ // Test SCRAM-SHA-256 configuration (non-IAM SASL)
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "SCRAM-SHA-256");
+ dataSourceProperties.put("property.sasl.username", "myuser");
+ dataSourceProperties.put("property.sasl.password", "mypassword");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("SASL_SSL",
props.getCustomKafkaProperties().get("security.protocol"));
+ Assert.assertEquals("SCRAM-SHA-256",
props.getCustomKafkaProperties().get("sasl.mechanism"));
+ }
+
+ @Test
+ public void testPlaintextConfigWithoutSasl() throws UserException {
+ // Test plain PLAINTEXT configuration (no SASL)
+ dataSourceProperties.put("property.security.protocol", "PLAINTEXT");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("PLAINTEXT",
props.getCustomKafkaProperties().get("security.protocol"));
+ }
+
+ @Test
+ public void testSslConfigWithoutSasl() throws UserException {
+ // Test SSL configuration without SASL
+ dataSourceProperties.put("property.security.protocol", "SSL");
+ dataSourceProperties.put("property.ssl.ca.location",
"/path/to/ca-cert");
+ dataSourceProperties.put("property.ssl.certificate.location",
"/path/to/client-cert");
+ dataSourceProperties.put("property.ssl.key.location",
"/path/to/client-key");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("SSL",
props.getCustomKafkaProperties().get("security.protocol"));
+ }
+
+ @Test
+ public void testPublicAccessWithExplicitCredentials() throws UserException
{
+ // Test public MSK access (broker contains "-public.") with explicit
credentials
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.access_key",
"AKIAIOSFODNN7EXAMPLE");
+ dataSourceProperties.put("property.aws.secret_key",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("us-east-1",
props.getCustomKafkaProperties().get("aws.region"));
+ Assert.assertEquals("AKIAIOSFODNN7EXAMPLE",
props.getCustomKafkaProperties().get("aws.access_key"));
+ }
+
+ @Test
+ public void testPublicAccessWithoutCredentials() {
+ // Test public MSK access without credentials - should fail
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for public access
without credentials");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("public"));
+ Assert.assertTrue(e.getMessage().contains("credentials"));
+ }
+ }
+
+ @Test
+ public void testInternalAccessWithInstanceProfile() throws UserException {
+ // Test internal MSK access with instance profile
(credentials_provider)
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.credentials_provider",
"INSTANCE_PROFILE");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("INSTANCE_PROFILE",
props.getCustomKafkaProperties().get("aws.credentials_provider"));
+ }
+
+ @Test
+ public void testRoleArnWithCredentialsProvider() throws UserException {
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyMskRole");
+ dataSourceProperties.put("property.aws.credentials_provider", "ENV");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("arn:aws:iam::123456789012:role/MyMskRole",
+ props.getCustomKafkaProperties().get("aws.role_arn"));
+ Assert.assertEquals("ENV",
props.getCustomKafkaProperties().get("aws.credentials_provider"));
+ }
+
+ @Test
+ public void testInternalAccessWithProfile() throws UserException {
+ // Test internal MSK access with AWS profile
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.profile_name", "default");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Assert.assertNotNull(props.getCustomKafkaProperties());
+ Assert.assertEquals("default",
props.getCustomKafkaProperties().get("aws.profile_name"));
+ }
+
+ @Test
+ public void testCrossAccountAccessWithRoleArnAndCredentials() throws
UserException {
+ // Test cross-account access: Account B's credentials + Account A's
role ARN
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.access_key",
"AKIAIOSFODNN7EXAMPLE");
+ dataSourceProperties.put("property.aws.secret_key",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::111111111111:role/AccountAMskRole");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ Map<String, String> customProps = props.getCustomKafkaProperties();
+ Assert.assertEquals("AKIAIOSFODNN7EXAMPLE",
customProps.get("aws.access_key"));
+ Assert.assertEquals("arn:aws:iam::111111111111:role/AccountAMskRole",
customProps.get("aws.role_arn"));
+ }
+
+ @Test
+ public void testMissingAccessKeyWithSecretKeyPublicAccess() {
+ // Test with only secret key but missing access key for PUBLIC access
- should fail
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.secret_key",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for missing access
key");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.access_key"));
+ Assert.assertTrue(e.getMessage().contains("aws.secret_key"));
+ Assert.assertTrue(e.getMessage().contains("together"));
+ }
+ }
+
+ @Test
+ public void testMissingSecretKeyWithAccessKeyPublicAccess() {
+ // Test with only access key but missing secret key for PUBLIC access
- should fail
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.access_key",
"AKIAIOSFODNN7EXAMPLE");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for missing secret
key");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.access_key"));
+ Assert.assertTrue(e.getMessage().contains("aws.secret_key"));
+ Assert.assertTrue(e.getMessage().contains("together"));
+ }
+ }
+
+ @Test
+ public void testPublicAccessWithProfileOnly() {
+ // Test public access with AWS profile only (no explicit credentials)
- should fail
+ // because public access requires explicit credentials
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.profile_name", "default");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for public access
without explicit credentials");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.access_key"));
+ Assert.assertTrue(e.getMessage().contains("aws.secret_key"));
+ Assert.assertTrue(e.getMessage().contains("together"));
+ }
+ }
+
+ @Test
+ public void testPublicAccessWithCredentialsProviderOnly() {
+ // Test public access with credentials provider only (no explicit
credentials) - should fail
+ // because public access requires explicit credentials
+ dataSourceProperties.put("kafka_broker_list",
"b-1-public.msk-cluster.xxx.kafka.us-east-1.amazonaws.com:9198");
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.credentials_provider", "ENV");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+
+ try {
+ props.analyze();
+ Assert.fail("Should throw AnalysisException for public access
without explicit credentials");
+ } catch (UserException e) {
+ Assert.assertTrue(e.getMessage().contains("aws.access_key"));
+ Assert.assertTrue(e.getMessage().contains("aws.secret_key"));
+ Assert.assertTrue(e.getMessage().contains("together"));
+ }
+ }
+
+ @Test
+ public void testMultipleCredentialsSources() throws UserException {
+ // Test with multiple credentials sources - all should be allowed
+ dataSourceProperties.put("property.security.protocol", "SASL_SSL");
+ dataSourceProperties.put("property.sasl.mechanism", "OAUTHBEARER");
+ dataSourceProperties.put("property.aws.region", "us-east-1");
+ dataSourceProperties.put("property.aws.access_key",
"AKIAIOSFODNN7EXAMPLE");
+ dataSourceProperties.put("property.aws.secret_key",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+ dataSourceProperties.put("property.aws.role_arn",
"arn:aws:iam::123456789012:role/MyRole");
+ dataSourceProperties.put("property.aws.external_id",
"external-id-123");
+ dataSourceProperties.put("property.aws.profile_name", "default");
+
+ KafkaDataSourceProperties props = new
KafkaDataSourceProperties(dataSourceProperties);
+ props.setTimezone("UTC");
+ props.analyze();
+
+ // All properties should be preserved (BE will use them in priority
order)
+ Map<String, String> customProps = props.getCustomKafkaProperties();
+ Assert.assertEquals("AKIAIOSFODNN7EXAMPLE",
customProps.get("aws.access_key"));
+ Assert.assertEquals("arn:aws:iam::123456789012:role/MyRole",
customProps.get("aws.role_arn"));
+ Assert.assertEquals("external-id-123",
customProps.get("aws.external_id"));
+ Assert.assertEquals("default", customProps.get("aws.profile_name"));
+ }
+}
diff --git a/regression-test/conf/regression-conf.groovy
b/regression-test/conf/regression-conf.groovy
index 17257831333..21f4bd047d8 100644
--- a/regression-test/conf/regression-conf.groovy
+++ b/regression-test/conf/regression-conf.groovy
@@ -90,6 +90,7 @@ excludeDirectories = "000_the_start_sentinel_do_not_touch," +
// keep this line
"segcompaction_p2," +
"workload_manager_p1," +
"cloud_p0/cache," +
+ "load_p0/msk_load," +
"zzz_the_end_sentinel_do_not_touch," +
"dialect_compatible"// keep this line as the last line
diff --git
a/regression-test/suites/load_p0/msk_load/test_routine_load_msk.groovy
b/regression-test/suites/load_p0/msk_load/test_routine_load_msk.groovy
new file mode 100644
index 00000000000..63fc38e6e13
--- /dev/null
+++ b/regression-test/suites/load_p0/msk_load/test_routine_load_msk.groovy
@@ -0,0 +1,177 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+import com.google.common.base.Strings;
+
+suite("test_routine_load_msk_access") {
+ def topicName = "routineload-test"
+ def tableName = "test_routine_load_iam_aksk_table"
+ def jobName = "test_routine_load_iam_aksk_job"
+
+ if
(Strings.isNullOrEmpty(context.config.otherConfigs.get("mskBrokerList"))) {
+ return
+ }
+
+ String ak = context.config.otherConfigs.get("awsMskAccessKey")
+ String sk = context.config.otherConfigs.get("awsMskSecretKey")
+ String region = context.config.otherConfigs.get("awsMskRegion")
+ String bootstrapBrokers = context.config.otherConfigs.get("mskBrokerList")
+ String role = context.config.otherConfigs.get("awsMskRole")
+ String sslCaLocation = context.config.otherConfigs.get("MskSslCaLocation")
+ String credProvider =
context.config.otherConfigs.get("CredentialsProvider")
+
+ logger.info("Using topic: ${topicName}")
+ logger.info("Bootstrap brokers: ${bootstrapBrokers}")
+ logger.info("AWS region: ${region}")
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE ${tableName} (
+ id INT,
+ name VARCHAR(50)
+ )
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ """
+
+ sql """
+ CREATE ROUTINE LOAD ${jobName} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS(id, name)
+ FROM KAFKA (
+ "kafka_broker_list" = "${bootstrapBrokers}",
+ "kafka_topic" = "${topicName}",
+ "aws.region" = "${region}",
+ "aws.access_key" = "${ak}",
+ "aws.secret_key" = "${sk}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING",
+ "property.security.protocol" = "SASL_SSL",
+ "property.sasl.mechanism" = "OAUTHBEARER",
+ "property.ssl.ca.location" = "${sslCaLocation}"
+ )
+ """
+ logger.info("Created routine load job: ${jobName}")
+
+ def maxRetries = 60
+ def retryCount = 0
+ def jobState = ""
+
+ while (retryCount < maxRetries) {
+ Thread.sleep(5000)
+
+ def jobStatus = sql """ SHOW ROUTINE LOAD FOR ${jobName} """
+ if (jobStatus.size() > 0) {
+ jobState = jobStatus[0][8]
+ logger.info("Job state: ${jobState}, retry: ${retryCount}")
+
+ if (jobState == "RUNNING") {
+ def result = sql """ SELECT COUNT(*) FROM ${tableName} """
+ if (result[0][0] > 0) {
+ logger.info("Data loaded successfully: ${result[0][0]}
rows")
+ break
+ }
+ } else if (jobState == "PAUSED" || jobState == "CANCELLED") {
+ logger.error("Job failed with state: ${jobState}")
+ logger.error("Job status: ${jobStatus[0]}")
+ break
+ }
+ }
+ retryCount++
+ }
+
+ def result = sql """ SELECT COUNT(*) FROM ${tableName} """
+ logger.info("Final row count: ${result[0][0]}")
+
+ assertTrue(result[0][0] > 0, "No data loaded from MSK. Job state:
${jobState}")
+
+ def sampleData = sql """ SELECT * FROM ${tableName} LIMIT 5 """
+ logger.info("Sample data: ${sampleData}")
+
+ sql """ STOP ROUTINE LOAD FOR ${jobName} """
+
+ // Test with role arn if configured
+ if (role) {
+ def roleTableName = "test_routine_load_iam_role_table"
+ def roleJobName = "test_routine_load_iam_role_job"
+
+ logger.info("Testing with AWS Role ARN: ${role}")
+
+ sql """ DROP TABLE IF EXISTS ${roleTableName} """
+ sql """
+ CREATE TABLE ${roleTableName} (
+ id INT,
+ name VARCHAR(50)
+ )
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ """
+
+ sql """
+ CREATE ROUTINE LOAD ${roleJobName} ON ${roleTableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS(id, name)
+ FROM KAFKA (
+ "kafka_broker_list" = "${bootstrapBrokers}",
+ "kafka_topic" = "${topicName}",
+ "aws.region" = "${region}",
+ "aws.role_arn" = "${role}",
+ "aws.credentials_provider" = "${credProvider}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING",
+ "property.security.protocol" = "SASL_SSL",
+ "property.sasl.mechanism" = "OAUTHBEARER",
+ "property.ssl.ca.location" = "${sslCaLocation}"
+ )
+ """
+ logger.info("Created routine load job with role: ${roleJobName}")
+
+ retryCount = 0
+ jobState = ""
+
+ while (retryCount < maxRetries) {
+ Thread.sleep(5000)
+
+ def jobStatus = sql """ SHOW ROUTINE LOAD FOR ${roleJobName} """
+ if (jobStatus.size() > 0) {
+ jobState = jobStatus[0][8]
+ logger.info("Role job state: ${jobState}, retry:
${retryCount}")
+
+ if (jobState == "RUNNING") {
+ def roleResult = sql """ SELECT COUNT(*) FROM
${roleTableName} """
+ if (roleResult[0][0] > 0) {
+ logger.info("Data loaded with role successfully:
${roleResult[0][0]} rows")
+ break
+ }
+ } else if (jobState == "PAUSED" || jobState == "CANCELLED") {
+ logger.error("Role job failed with state: ${jobState}")
+ logger.error("Job status: ${jobStatus[0]}")
+ break
+ }
+ }
+ retryCount++
+ }
+
+ def roleResult = sql """ SELECT COUNT(*) FROM ${roleTableName} """
+ logger.info("Role job final row count: ${roleResult[0][0]}")
+
+ assertTrue(roleResult[0][0] > 0, "No data loaded with role ARN. Job
state: ${jobState}")
+
+ sql """ STOP ROUTINE LOAD FOR ${roleJobName} """
+ logger.info("Role ARN test completed successfully")
+ }
+}
diff --git
a/regression-test/suites/load_p0/msk_load/test_routine_load_msk_public_access.groovy
b/regression-test/suites/load_p0/msk_load/test_routine_load_msk_public_access.groovy
new file mode 100644
index 00000000000..cb25324b119
--- /dev/null
+++
b/regression-test/suites/load_p0/msk_load/test_routine_load_msk_public_access.groovy
@@ -0,0 +1,105 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+import com.google.common.base.Strings;
+
+suite("test_routine_load_msk_public_access") {
+ def topicName = "routineload-test"
+ def tableName = "test_routine_load_iam_aksk_table"
+ def jobName = "test_routine_load_iam_aksk_job"
+
+ if
(Strings.isNullOrEmpty(context.config.otherConfigs.get("publicMskBrokerList")))
{
+ return
+ }
+
+ def ak = context.config.awsMskAccessKey
+ def sk = context.config.awsMskSecretKey
+ def region = context.config.awsMskRegion
+ String bootstrapBrokers = context.config.otherConfigs.get("mskBrokerList")
+ String role = context.config.otherConfigs.get("awsMskRole")
+ String sslCaLocation = context.config.otherConfigs.get("MskSslCaLocation")
+
+ logger.info("Using topic: ${topicName}")
+ logger.info("Bootstrap brokers: ${bootstrapBrokers}")
+ logger.info("AWS region: ${region}")
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE ${tableName} (
+ id INT,
+ name VARCHAR(50)
+ )
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ """
+
+ sql """
+ CREATE ROUTINE LOAD ${jobName} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS(id, name)
+ FROM KAFKA (
+ "kafka_broker_list" = "${bootstrapBrokers}",
+ "kafka_topic" = "${topicName}",
+ "aws.region" = "${region}",
+ "aws.access_key" = "${ak}",
+ "aws.secret_key" = "${sk}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING",
+ "property.security.protocol" = "SASL_SSL",
+ "property.sasl.mechanism" = "OAUTHBEARER",
+ "property.ssl.ca.location" = "${sslCaLocation}"
+ )
+ """
+ logger.info("Created routine load job: ${jobName}")
+
+ def maxRetries = 60
+ def retryCount = 0
+ def jobState = ""
+
+ while (retryCount < maxRetries) {
+ Thread.sleep(5000)
+
+ def jobStatus = sql """ SHOW ROUTINE LOAD FOR ${jobName} """
+ if (jobStatus.size() > 0) {
+ jobState = jobStatus[0][8]
+ logger.info("Job state: ${jobState}, retry: ${retryCount}")
+
+ if (jobState == "RUNNING") {
+ def result = sql """ SELECT COUNT(*) FROM ${tableName} """
+ if (result[0][0] > 0) {
+ logger.info("Data loaded successfully: ${result[0][0]}
rows")
+ break
+ }
+ } else if (jobState == "PAUSED" || jobState == "CANCELLED") {
+ logger.error("Job failed with state: ${jobState}")
+ logger.error("Job status: ${jobStatus[0]}")
+ break
+ }
+ }
+ retryCount++
+ }
+
+ def result = sql """ SELECT COUNT(*) FROM ${tableName} """
+ logger.info("Final row count: ${result[0][0]}")
+
+ assertTrue(result[0][0] > 0, "No data loaded from MSK. Job state:
${jobState}")
+
+ def sampleData = sql """ SELECT * FROM ${tableName} LIMIT 5 """
+ logger.info("Sample data: ${sampleData}")
+
+ sql """ STOP ROUTINE LOAD FOR ${jobName} """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]