This is an automated email from the ASF dual-hosted git repository.
HeartSaVioR pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 52eb078867cf [SPARK-28173][SS] Support obtaining Kafka delegation
token for proxy user
52eb078867cf is described below
commit 52eb078867cf809a92fd6ebdc96c9d72bce63417
Author: Cheng Pan <[email protected]>
AuthorDate: Thu Aug 20 16:26:38 2026 +0900
[SPARK-28173][SS] Support obtaining Kafka delegation token for proxy user
### What changes were proposed in this pull request?
Support obtaining Kafka delegation tokens when the current user is a proxy
user, based on
KAFKA-6945 (Kafka 3.3.0):
- `KafkaTokenUtil.obtainToken` requests the token with
`CreateDelegationTokenOptions.owner(<proxy user>)` when the current UGI
is a proxy user, so the
token is requested with the real user's credentials but owned by the
proxy user. The
`checkProxyUser()` guard that rejected proxy users is removed.
- The two failure modes of the owner-set path are translated into
actionable `SparkException`s:
`UnsupportedVersionException` (broker older than 3.3.0) and
`DelegationTokenAuthorizationException` (real user lacks the
`CreateTokens` operation on the
`User:<proxy user>` resource).
- `printToken` logs the token's REQUESTER alongside OWNER, which differ for
proxy-user tokens.
- The SS Kafka integration guide's caveat is updated: broker version
requirement, the
`kafka-acls.sh` grant example, and the constraint that the real user's
credentials come from the
ticket cache (`--proxy-user` cannot be combined with
`--principal`/`--keytab`) with the token
obtained once and not renewed.
### Why are the changes needed?
Applications submitted with `--proxy-user` cannot use Kafka delegation
tokens: token acquisition
is skipped with a warning, and connectors fall back to other
authentication. KAFKA-6945 added the
ability to create tokens owned by another principal, which makes
impersonation work end to end.
### Does this PR introduce _any_ user-facing change?
Yes. Proxy-user applications now obtain Kafka delegation tokens owned by
the proxy user
(previously acquisition was skipped with a warning). Requires Kafka 3.3.0+
brokers and the real
user granted `CreateTokens` on the `User:<proxy user>` resource;
misconfigurations of either now
fail with a descriptive error.
### How was this patch tested?
UT/IT:
- `KafkaTokenUtilSuite`: `createDelegationTokenOptions` sets the owner for
a proxy user and
leaves it unset otherwise.
- `KafkaDelegationTokenSuite` (embedded secure Kafka + MiniKdc): asserts
the broker-recorded
owner/requester for both the plain and the proxy-user roundtrip; the
proxy roundtrip runs with
DENY read/write ACLs for the real user on the test topic so it passes
only if connectors
authenticate as the proxy user; a negative test asserts the descriptive
error when
`CreateTokens` is not granted.
Real cluster tests:
- Plain token via `KafkaTokenUtil.obtainToken` — → success (token owner
`User:spark`, requester `User:spark`).
- Proxy-user token - `alice` with CreateTokens ACL → success (token owner
`User:alice`).
- Proxy-user token - `bob` without ACL → failure, clean `SparkException:
The real user must be granted the CreateTokens operation on the User:bob
resource.`; broker recorded 0 tokens for `bob`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (claude-fable-5)
Closes #58064 from pan3793/SPARK-28173.
Authored-by: Cheng Pan <[email protected]>
Signed-off-by: Jungtaek Lim <[email protected]>
---
.../sql/kafka010/KafkaDelegationTokenSuite.scala | 130 +++++++++++++++++++--
.../apache/spark/sql/kafka010/KafkaTestUtils.scala | 94 ++++++++++++---
.../org/apache/spark/kafka010/KafkaTokenUtil.scala | 55 ++++++---
.../spark/kafka010/KafkaTokenUtilSuite.scala | 21 +++-
.../structured-streaming-kafka-integration.md | 13 ++-
5 files changed, 272 insertions(+), 41 deletions(-)
diff --git
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaDelegationTokenSuite.scala
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaDelegationTokenSuite.scala
index a55b6e006851..5d6356cb0dad 100644
---
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaDelegationTokenSuite.scala
+++
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaDelegationTokenSuite.scala
@@ -17,15 +17,23 @@
package org.apache.spark.sql.kafka010
+import java.security.PrivilegedExceptionAction
import java.util.UUID
+import java.util.concurrent.ExecutionException
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.security.{Credentials, UserGroupInformation}
+import org.apache.kafka.common.acl.{AccessControlEntry, AclBinding,
AclOperation, AclPermissionType}
+import org.apache.kafka.common.errors.DelegationTokenAuthorizationException
+import org.apache.kafka.common.resource.{PatternType, ResourcePattern,
ResourceType}
import org.apache.kafka.common.security.auth.SecurityProtocol.SASL_PLAINTEXT
+import org.apache.kafka.common.security.token.delegation.TokenInformation
+import org.apache.spark.SparkException
import org.apache.spark.deploy.SparkHadoopUtil
import org.apache.spark.deploy.security.HadoopDelegationTokenManager
import org.apache.spark.internal.config.{KEYTAB, PRINCIPAL}
+import org.apache.spark.kafka010.{KafkaTokenSparkConf, KafkaTokenUtil}
import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
import org.apache.spark.sql.streaming.{OutputMode, StreamTest}
import org.apache.spark.sql.test.SharedSparkSession
@@ -34,6 +42,13 @@ class KafkaDelegationTokenSuite extends StreamTest with
SharedSparkSession with
import testImplicits._
+ private val clusterIdentifier = "cluster1"
+
+ // The client principal is allowed to obtain tokens for `proxyUser` but not
for
+ // `deniedProxyUser`.
+ private val proxyUser = "proxyUser"
+ private val deniedProxyUser = "deniedProxyUser"
+
protected var testUtils: KafkaTestUtils = _
protected override def sparkConf = super.sparkConf
@@ -41,12 +56,26 @@ class KafkaDelegationTokenSuite extends StreamTest with
SharedSparkSession with
.set("spark.security.credentials.hbase.enabled", "false")
.set(KEYTAB, testUtils.clientKeytab)
.set(PRINCIPAL, testUtils.clientPrincipal)
- .set("spark.kafka.clusters.cluster1.auth.bootstrap.servers",
testUtils.brokerAddress)
- .set("spark.kafka.clusters.cluster1.security.protocol",
SASL_PLAINTEXT.name)
+ .set(s"spark.kafka.clusters.$clusterIdentifier.auth.bootstrap.servers",
testUtils.brokerAddress)
+ .set(s"spark.kafka.clusters.$clusterIdentifier.security.protocol",
SASL_PLAINTEXT.name)
override def beforeAll(): Unit = {
testUtils = new KafkaTestUtils(Map.empty, true)
- testUtils.setup()
+ try {
+ testUtils.setup(
+ testUtils.allowAllAcls(testUtils.kafkaPrincipal(proxyUser).toString) :+
+ createTokensAcl(proxyUser))
+ } catch {
+ case e: Throwable =>
+ // ScalaTest skips afterAll when beforeAll throws, so tear down here
to avoid leaking
+ // the KDC, broker, and the global JAAS system property into later
suites.
+ try {
+ testUtils.teardown()
+ } finally {
+ testUtils = null
+ }
+ throw e
+ }
super.beforeAll()
}
@@ -62,17 +91,53 @@ class KafkaDelegationTokenSuite extends StreamTest with
SharedSparkSession with
}
}
- testRetry("Roundtrip", 3) {
- val hadoopConf = new Configuration()
- val manager = new HadoopDelegationTokenManager(spark.sparkContext.conf,
hadoopConf, null)
+ /**
+ * Allow the client principal to obtain tokens owned by `owner`. The broker
looks the ACL up
+ * by the `KafkaPrincipal.toString` of the owner, hence the `User:` prefixed
resource name.
+ */
+ private def createTokensAcl(owner: String): AclBinding = new AclBinding(
+ new ResourcePattern(
+ ResourceType.USER, testUtils.kafkaPrincipal(owner).toString,
PatternType.LITERAL),
+ new AccessControlEntry(
+ testUtils.clientKafkaPrincipal, "*", AclOperation.CREATE_TOKENS,
AclPermissionType.ALLOW))
+
+ private def proxyUgi(user: String): UserGroupInformation =
+ UserGroupInformation.createProxyUser(user,
UserGroupInformation.getCurrentUser())
+
+ private def obtainDelegationTokens(ugi: UserGroupInformation): Credentials =
{
val credentials = new Credentials()
- manager.obtainDelegationTokens(credentials)
+ ugi.doAs(new PrivilegedExceptionAction[Unit]() {
+ override def run(): Unit = {
+ val manager = new HadoopDelegationTokenManager(
+ spark.sparkContext.conf, new Configuration(), null)
+ manager.obtainDelegationTokens(credentials)
+ }
+ })
+ credentials
+ }
+
+ /** Ask the broker what it recorded for the token which was just obtained. */
+ private def describeToken(credentials: Credentials): TokenInformation = {
+ val token =
credentials.getToken(KafkaTokenUtil.getTokenService(clusterIdentifier))
+ assert(token != null, s"No delegation token was obtained for cluster
$clusterIdentifier")
+ val tokenId = new String(token.getIdentifier)
+ testUtils.describeDelegationTokens().find(_.tokenId() == tokenId)
+ .getOrElse(fail(s"Token $tokenId is unknown to the broker"))
+ }
+
+ private def distributeTokens(credentials: Credentials): Unit = {
val serializedCredentials = SparkHadoopUtil.get.serialize(credentials)
SparkHadoopUtil.get.addDelegationTokens(serializedCredentials,
spark.sparkContext.conf)
+ }
+ private def createTopic(): String = {
val topic = "topic-" + UUID.randomUUID().toString
testUtils.createTopic(topic, partitions = 5)
+ topic
+ }
+ /** Write to and read back from `topic`, authenticating with the distributed
token. */
+ private def roundtrip(topic: String): Unit = {
withTempDir { checkpointDir =>
val input = MemoryStream[String]
@@ -115,4 +180,55 @@ class KafkaDelegationTokenSuite extends StreamTest with
SharedSparkSession with
StopStream
)
}
+
+ testRetry("Roundtrip", 3) {
+ val credentials =
obtainDelegationTokens(UserGroupInformation.getCurrentUser())
+
+ // Without impersonation no owner is sent, so the broker assigns ownership
to the requester.
+ val tokenInfo = describeToken(credentials)
+ assert(tokenInfo.owner().toString === testUtils.clientKafkaPrincipal)
+ assert(tokenInfo.tokenRequester().toString ===
testUtils.clientKafkaPrincipal)
+
+ distributeTokens(credentials)
+ roundtrip(createTopic())
+ }
+
+ testRetry("SPARK-28173: Roundtrip with proxy user", 3) {
+ // The manager preserves the proxy UGI here despite KEYTAB/PRINCIPAL being
set only because
+ // Hadoop security is SIMPLE in this JVM (a keytab re-login is then a
no-op). The production
+ // proxy flow (ticket cache or direct providers, no principal) preserves
it by design.
+ val credentials = obtainDelegationTokens(proxyUgi(proxyUser))
+
+ // The token is requested with the client's credentials but owned by the
proxy user, so
+ // connectors authenticate to Kafka as the proxy user.
+ val tokenInfo = describeToken(credentials)
+ assert(tokenInfo.owner() === testUtils.kafkaPrincipal(proxyUser))
+ assert(tokenInfo.tokenRequester().toString ===
testUtils.clientKafkaPrincipal)
+
+ distributeTokens(credentials)
+ val topic = createTopic()
+ // Deny the client principal on the topic so the roundtrip only succeeds
when the connectors
+ // authenticate as the proxy user (deny overrides the wildcard allow).
+ testUtils.createAcls(Seq(AclOperation.READ, AclOperation.WRITE).map { op =>
+ new AclBinding(
+ new ResourcePattern(ResourceType.TOPIC, topic, PatternType.LITERAL),
+ new AccessControlEntry(testUtils.clientKafkaPrincipal, "*", op,
AclPermissionType.DENY))
+ })
+ roundtrip(topic)
+ }
+
+ test("SPARK-28173: Obtaining token for proxy user without CreateTokens
permission fails") {
+ val conf = spark.sparkContext.conf
+ val clusterConf = KafkaTokenSparkConf.getClusterConfig(conf,
clusterIdentifier)
+ proxyUgi(deniedProxyUser).doAs(new PrivilegedExceptionAction[Unit]() {
+ override def run(): Unit = {
+ val e = intercept[SparkException] {
+ KafkaTokenUtil.obtainToken(conf, clusterConf)
+ }
+ assert(e.getMessage.contains("CreateTokens"))
+ assert(e.getCause.asInstanceOf[ExecutionException]
+ .getCause.isInstanceOf[DelegationTokenAuthorizationException])
+ }
+ })
+ }
}
diff --git
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaTestUtils.scala
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaTestUtils.scala
index b31f6af1c794..14ba16667ac8 100644
---
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaTestUtils.scala
+++
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaTestUtils.scala
@@ -28,6 +28,7 @@ import scala.io.Source
import scala.jdk.CollectionConverters._
import kafka.log.LogManager
+import kafka.security.authorizer.AclAuthorizer
import kafka.server.{HostedPartition, KafkaConfig, KafkaServer}
import kafka.server.checkpoints.OffsetCheckpointFile
import kafka.zk.KafkaZkClient
@@ -37,10 +38,14 @@ import org.apache.kafka.clients.CommonClientConfigs
import org.apache.kafka.clients.admin._
import org.apache.kafka.clients.producer._
import org.apache.kafka.common.TopicPartition
+import org.apache.kafka.common.acl.{AccessControlEntry, AclBinding,
AclOperation, AclPermissionType}
import org.apache.kafka.common.config.SaslConfigs
import org.apache.kafka.common.network.ListenerName
import org.apache.kafka.common.requests.FetchRequest
+import org.apache.kafka.common.resource.{PatternType, Resource,
ResourcePattern, ResourceType}
+import org.apache.kafka.common.security.auth.KafkaPrincipal
import org.apache.kafka.common.security.auth.SecurityProtocol.{PLAINTEXT,
SASL_PLAINTEXT}
+import org.apache.kafka.common.security.token.delegation.TokenInformation
import org.apache.kafka.common.serialization.StringSerializer
import org.apache.kafka.common.utils.Time
import org.apache.zookeeper.client.ZKClientConfig
@@ -95,7 +100,10 @@ class KafkaTestUtils(
private var brokerConf: KafkaConfig = _
private val brokerServiceName = "kafka"
- private val clientUser = s"client/$localCanonicalHostName"
+ private val brokerUser = s"$brokerServiceName/$localCanonicalHostName"
+ private var brokerKeytabFile: File = _
+ private val clientShortName = "client"
+ private val clientUser = s"$clientShortName/$localCanonicalHostName"
private var clientKeytabFile: File = _
// Kafka broker server
@@ -137,6 +145,16 @@ class KafkaTestUtils(
clientKeytabFile.getAbsolutePath()
}
+ /** The Kafka principal for `user`, i.e. `User:<user>`. */
+ def kafkaPrincipal(user: String): KafkaPrincipal =
+ new KafkaPrincipal(KafkaPrincipal.USER_TYPE, user)
+
+ /**
+ * The Kafka principal the client keytab authenticates as. The broker
derives it from
+ * `client/<host>@<realm>` with the default
`sasl.kerberos.principal.to.local.rules`.
+ */
+ def clientKafkaPrincipal: String = kafkaPrincipal(clientShortName).toString
+
private def setUpMiniKdc(): Unit = {
val kdcDir = Utils.createTempDir()
val kdcConf = MiniKdc.createConf()
@@ -197,10 +215,9 @@ class KafkaTestUtils(
kdc.createPrincipal(zkClientKeytabFile, zkClientUser)
logDebug(s"Created keytab file: ${zkClientKeytabFile.getAbsolutePath()}")
- val kafkaServerUser = s"kafka/$localCanonicalHostName"
- val kafkaServerKeytabFile = new File(baseDir, "kafka.keytab")
- kdc.createPrincipal(kafkaServerKeytabFile, kafkaServerUser)
- logDebug(s"Created keytab file:
${kafkaServerKeytabFile.getAbsolutePath()}")
+ brokerKeytabFile = new File(baseDir, "kafka.keytab")
+ kdc.createPrincipal(brokerKeytabFile, brokerUser)
+ logDebug(s"Created keytab file: ${brokerKeytabFile.getAbsolutePath()}")
clientKeytabFile = new File(baseDir, "client.keytab")
kdc.createPrincipal(clientKeytabFile, clientUser)
@@ -235,8 +252,8 @@ class KafkaTestUtils(
| serviceName="$brokerServiceName"
| useKeyTab=true
| storeKey=true
- | keyTab="${kafkaServerKeytabFile.getAbsolutePath()}"
- | principal="$kafkaServerUser@$realm";
+ | keyTab="${brokerKeytabFile.getAbsolutePath()}"
+ | principal="$brokerUser@$realm";
|};
""".stripMargin.trim
Files.writeString(file.toPath, content)
@@ -276,8 +293,12 @@ class KafkaTestUtils(
brokerReady = true
}
- /** setup the whole embedded servers, including Zookeeper and Kafka brokers
*/
- def setup(): Unit = {
+ /**
+ * Setup the whole embedded servers, including Zookeeper and Kafka brokers.
In secure mode,
+ * `extraAcls` are created together with the client principal's allow-all
ACLs.
+ */
+ def setup(extraAcls: Seq[AclBinding] = Nil): Unit = {
+ assert(secure || extraAcls.isEmpty, "ACLs require the cluster to be set up
in secure mode")
// Set up a KafkaTestUtils leak detector so that we can see where the leak
KafkaTestUtils is
// created.
val exception = new SparkException("It was created at: ")
@@ -299,8 +320,45 @@ class KafkaTestUtils(
eventually(timeout(1.minute)) {
assert(zkClient.getAllBrokersInCluster.nonEmpty, "Broker was not up in
60 seconds")
}
+ if (secure) {
+ createAcls(allowAllAcls(clientKafkaPrincipal) ++ extraAcls)
+ }
}
+ /** All the ACLs granting `principal` unrestricted access to the cluster. */
+ def allowAllAcls(principal: String): Seq[AclBinding] = {
+ val entry = new AccessControlEntry(principal, "*", AclOperation.ALL,
AclPermissionType.ALLOW)
+ def wildcard(resourceType: ResourceType): ResourcePattern =
+ new ResourcePattern(resourceType, ResourcePattern.WILDCARD_RESOURCE,
PatternType.LITERAL)
+ Seq(
+ wildcard(ResourceType.TOPIC),
+ wildcard(ResourceType.GROUP),
+ wildcard(ResourceType.TRANSACTIONAL_ID),
+ new ResourcePattern(ResourceType.CLUSTER, Resource.CLUSTER_NAME,
PatternType.LITERAL)
+ ).map(new AclBinding(_, entry))
+ }
+
+ /**
+ * Add ACLs as a super user. The single zk-mode broker updates its
authorizer cache before
+ * completing the request, so the ACLs are enforced once this returns.
+ */
+ def createAcls(bindings: Seq[AclBinding]): Unit = {
+ assert(secure, "ACLs are only enforced when the cluster is set up in
secure mode")
+ val props = new Properties()
+ props.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, brokerAddress)
+ addSaslClientProps(props, brokerKeytabFile,
s"$brokerUser@${kdc.getRealm()}")
+ val superuserAdminClient = AdminClient.create(props)
+ try {
+ superuserAdminClient.createAcls(bindings.asJava).all().get()
+ } finally {
+ superuserAdminClient.close()
+ }
+ }
+
+ /** All delegation tokens the broker knows, as seen by the client principal.
*/
+ def describeDelegationTokens(): Seq[TokenInformation] =
+
adminClient.describeDelegationToken().delegationTokens().get().asScala.toSeq.map(_.tokenInfo())
+
/** Teardown the whole servers, including Kafka broker and Zookeeper */
def teardown(): Unit = {
if (leakDetector != null) {
@@ -501,6 +559,13 @@ class KafkaTestUtils(
props.put("inter.broker.listener.name", "SASL_PLAINTEXT")
props.put("delegation.token.master.key", UUID.randomUUID().toString)
props.put("sasl.enabled.mechanisms", "GSSAPI,SCRAM-SHA-512")
+ // Enforce ACLs so that delegation token authorization can be tested.
The broker
+ // authenticates to itself as `kafka/<host>@<realm>`, which the default
+ // principal-to-local rules map to `User:kafka`, and it must stay
unrestricted.
+ // `setup` grants the client principal full access, so tests which do
not care about
+ // ACLs are unaffected.
+ props.put("authorizer.class.name", classOf[AclAuthorizer].getName)
+ props.put("super.users", kafkaPrincipal(brokerServiceName).toString)
}
props.putAll(withBrokerProps.asJava)
@@ -542,13 +607,16 @@ class KafkaTestUtils(
private def setAuthenticationConfigIfNeeded(props: Properties): Unit = {
if (secure) {
- val jaasParams = KafkaTokenUtil.getKeytabJaasParams(
- clientKeytabFile.getAbsolutePath, clientPrincipal, brokerServiceName)
- props.put(SaslConfigs.SASL_JAAS_CONFIG, jaasParams)
- props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG,
SASL_PLAINTEXT.name)
+ addSaslClientProps(props, clientKeytabFile, clientPrincipal)
}
}
+ private def addSaslClientProps(props: Properties, keytabFile: File,
principal: String): Unit = {
+ props.put(SaslConfigs.SASL_JAAS_CONFIG, KafkaTokenUtil.getKeytabJaasParams(
+ keytabFile.getAbsolutePath, principal, brokerServiceName))
+ props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG,
SASL_PLAINTEXT.name)
+ }
+
/** Verify topic is deleted in all places, e.g, brokers, zookeeper. */
private def verifyTopicDeletion(
topic: String,
diff --git
a/connector/kafka-0-10-token-provider/src/main/scala/org/apache/spark/kafka010/KafkaTokenUtil.scala
b/connector/kafka-0-10-token-provider/src/main/scala/org/apache/spark/kafka010/KafkaTokenUtil.scala
index cbcc27ff7d28..9a2325cce3b2 100644
---
a/connector/kafka-0-10-token-provider/src/main/scala/org/apache/spark/kafka010/KafkaTokenUtil.scala
+++
b/connector/kafka-0-10-token-provider/src/main/scala/org/apache/spark/kafka010/KafkaTokenUtil.scala
@@ -20,6 +20,7 @@ package org.apache.spark.kafka010
import java.{util => ju}
import java.time.{Instant, ZoneId}
import java.time.format.DateTimeFormatter
+import java.util.concurrent.ExecutionException
import java.util.regex.Pattern
import scala.jdk.CollectionConverters._
@@ -32,14 +33,17 @@ import
org.apache.hadoop.security.token.delegation.AbstractDelegationTokenIdenti
import org.apache.kafka.clients.CommonClientConfigs
import org.apache.kafka.clients.admin.{AdminClient,
CreateDelegationTokenOptions}
import org.apache.kafka.common.config.{SaslConfigs, SslConfigs}
+import org.apache.kafka.common.errors.{DelegationTokenAuthorizationException,
UnsupportedVersionException}
import org.apache.kafka.common.security.JaasContext
+import org.apache.kafka.common.security.auth.KafkaPrincipal
import org.apache.kafka.common.security.auth.SecurityProtocol.{SASL_PLAINTEXT,
SASL_SSL, SSL}
import org.apache.kafka.common.security.scram.ScramLoginModule
import org.apache.kafka.common.security.token.delegation.DelegationToken
-import org.apache.spark.SparkConf
+import org.apache.spark.{SparkConf, SparkException}
import org.apache.spark.deploy.SparkHadoopUtil
import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.USER_NAME
import org.apache.spark.internal.config._
import org.apache.spark.util.{SecurityUtils, Utils}
import org.apache.spark.util.Utils.REDACTION_REPLACEMENT_TEXT
@@ -65,13 +69,21 @@ object KafkaTokenUtil extends Logging {
def obtainToken(
sparkConf: SparkConf,
clusterConf: KafkaTokenClusterConf):
(Token[KafkaDelegationTokenIdentifier], Long) = {
- checkProxyUser()
-
+ val options = createDelegationTokenOptions()
val adminClient =
AdminClient.create(createAdminClientProperties(sparkConf, clusterConf))
val token = try {
- val createDelegationTokenOptions = new CreateDelegationTokenOptions()
- val createResult =
adminClient.createDelegationToken(createDelegationTokenOptions)
- createResult.delegationToken().get()
+ adminClient.createDelegationToken(options).delegationToken().get()
+ } catch {
+ case e: ExecutionException if options.owner().isPresent =>
+ e.getCause match {
+ case _: UnsupportedVersionException =>
+ throw new SparkException("Obtaining delegation token for proxy
user requires " +
+ "Kafka 3.3.0 or later brokers (KAFKA-6945).", e)
+ case _: DelegationTokenAuthorizationException =>
+ throw new SparkException("The real user must be granted the
CreateTokens operation " +
+ s"on the ${options.owner().get()} resource.", e)
+ case _ => throw e
+ }
} finally {
Utils.closeQuietly(adminClient)
}
@@ -85,12 +97,26 @@ object KafkaTokenUtil extends Logging {
), token.tokenInfo.expiryTimestamp)
}
- def checkProxyUser(): Unit = {
+ /**
+ * When impersonating, the token is requested with the real user's
credentials but owned by the
+ * proxy user, so connectors authenticate to Kafka as the proxy user. This
needs Kafka 3.3.0 or
+ * later brokers (KAFKA-6945), and the real user must be granted the
`CreateTokens` operation on
+ * the `User:<proxy user>` resource.
+ *
+ * Must be invoked under the UGI that should own the token.
HadoopDelegationTokenManager
+ * preserves a proxy UGI only when no principal/keytab is configured
(spark-submit forbids
+ * combining --proxy-user with --principal) or when direct credential
providers are used;
+ * a keytab re-login drops the proxy identity and the token is then owned by
the real user.
+ */
+ private[kafka010] def createDelegationTokenOptions():
CreateDelegationTokenOptions = {
+ val options = new CreateDelegationTokenOptions()
val currentUser = UserGroupInformation.getCurrentUser()
- // Obtaining delegation token for proxy user is planned but not yet
implemented
- // See https://issues.apache.org/jira/browse/KAFKA-6945
- require(!SparkHadoopUtil.get.isProxyUser(currentUser), "Obtaining
delegation token for proxy " +
- "user is not yet supported.")
+ if (SparkHadoopUtil.get.isProxyUser(currentUser)) {
+ val owner = currentUser.getUserName
+ logInfo(log"Obtaining kafka delegation token for proxy user
${MDC(USER_NAME, owner)}.")
+ options.owner(new KafkaPrincipal(KafkaPrincipal.USER_TYPE, owner))
+ }
+ options
}
def createAdminClientProperties(
@@ -229,13 +255,14 @@ object KafkaTokenUtil extends Logging {
private def printToken(token: DelegationToken): Unit = {
if (log.isDebugEnabled) {
- logDebug("%-15s %-30s %-15s %-25s %-15s %-15s %-15s".format(
- "TOKENID", "HMAC", "OWNER", "RENEWERS", "ISSUEDATE", "EXPIRYDATE",
"MAXDATE"))
+ logDebug("%-15s %-30s %-15s %-15s %-25s %-15s %-15s %-15s".format(
+ "TOKENID", "HMAC", "OWNER", "REQUESTER", "RENEWERS", "ISSUEDATE",
"EXPIRYDATE", "MAXDATE"))
val tokenInfo = token.tokenInfo
- logDebug("%-15s %-15s %-15s %-25s %-15s %-15s %-15s".format(
+ logDebug("%-15s %-30s %-15s %-15s %-25s %-15s %-15s %-15s".format(
tokenInfo.tokenId,
REDACTION_REPLACEMENT_TEXT,
tokenInfo.owner,
+ tokenInfo.tokenRequester,
tokenInfo.renewersAsString,
DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(tokenInfo.issueTimestamp)),
DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(tokenInfo.expiryTimestamp)),
diff --git
a/connector/kafka-0-10-token-provider/src/test/scala/org/apache/spark/kafka010/KafkaTokenUtilSuite.scala
b/connector/kafka-0-10-token-provider/src/test/scala/org/apache/spark/kafka010/KafkaTokenUtilSuite.scala
index 8a606a1adc76..c7d6fa647db9 100644
---
a/connector/kafka-0-10-token-provider/src/test/scala/org/apache/spark/kafka010/KafkaTokenUtilSuite.scala
+++
b/connector/kafka-0-10-token-provider/src/test/scala/org/apache/spark/kafka010/KafkaTokenUtilSuite.scala
@@ -26,6 +26,7 @@ import org.apache.hadoop.io.Text
import org.apache.hadoop.security.UserGroupInformation
import org.apache.kafka.clients.CommonClientConfigs
import org.apache.kafka.common.config.{SaslConfigs, SslConfigs}
+import org.apache.kafka.common.security.auth.KafkaPrincipal
import org.apache.kafka.common.security.auth.SecurityProtocol.{SASL_PLAINTEXT,
SASL_SSL, SSL}
import org.apache.spark.{SparkConf, SparkFunSuite}
@@ -39,16 +40,24 @@ class KafkaTokenUtilSuite extends SparkFunSuite with
KafkaDelegationTokenTest {
sparkConf = new SparkConf()
}
- test("checkProxyUser with proxy current user should throw exception") {
+ test("SPARK-28173: createDelegationTokenOptions without proxy user should
not set owner") {
+ UserGroupInformation.createUserForTesting("realUser", Array()).doAs(
+ new PrivilegedExceptionAction[Unit]() {
+ override def run(): Unit = {
+
assert(!KafkaTokenUtil.createDelegationTokenOptions().owner().isPresent)
+ }
+ }
+ )
+ }
+
+ test("SPARK-28173: createDelegationTokenOptions with proxy user should set
owner") {
val realUser = UserGroupInformation.createUserForTesting("realUser",
Array())
UserGroupInformation.createProxyUserForTesting("proxyUser", realUser,
Array()).doAs(
new PrivilegedExceptionAction[Unit]() {
override def run(): Unit = {
- val thrown = intercept[IllegalArgumentException] {
- KafkaTokenUtil.checkProxyUser()
- }
- assert(thrown.getMessage contains
- "Obtaining delegation token for proxy user is not yet supported.")
+ val owner = KafkaTokenUtil.createDelegationTokenOptions().owner()
+ assert(owner.isPresent)
+ assert(owner.get() === new KafkaPrincipal(KafkaPrincipal.USER_TYPE,
"proxyUser"))
}
}
)
diff --git a/docs/streaming/structured-streaming-kafka-integration.md
b/docs/streaming/structured-streaming-kafka-integration.md
index 17741fc55644..105dac7fb4fc 100644
--- a/docs/streaming/structured-streaming-kafka-integration.md
+++ b/docs/streaming/structured-streaming-kafka-integration.md
@@ -1226,7 +1226,18 @@ For possible Kafka parameters, see [Kafka adminclient
config docs](http://kafka.
#### Caveats
-- Obtaining delegation token for proxy user is not yet supported
([KAFKA-6945](https://issues.apache.org/jira/browse/KAFKA-6945)).
+- Obtaining delegation token for [proxy user](../security.html#proxy-user)
requires Kafka broker 3.3.0 or higher
+ ([KAFKA-6945](https://issues.apache.org/jira/browse/KAFKA-6945)). The token
is requested with the real user's
+ credentials but owned by the proxy user, therefore the real user must be
granted the `CreateTokens` operation
+ on the `User:<proxy user>` resource, such as,
+
+ ./bin/kafka-acls.sh --bootstrap-server <KAFKA_SERVERS> --add \
+ --allow-principal User:<real user> --operation CreateTokens
--user-principal "User:<proxy user>"
+
+ Since `--proxy-user` cannot be combined with `--principal`/`--keytab`, the
real user's Kerberos credentials
+ come from the ticket cache, and the token is obtained once at startup and
not renewed: the application must
+ finish before the token's max lifetime, unless direct credential providers
+ (`spark.security.directCredentialProviders.enabled`) with non-Kerberos Kafka
authentication are used.
### JAAS login configuration
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]