Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.apache.spark.deploy.SparkHadoopUtil
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys
import org.apache.spark.internal.config._
import org.apache.spark.internal.config.Network.NETWORK_CRYPTO_ENABLED
import org.apache.spark.rpc.RpcEndpointRef
import org.apache.spark.scheduler.cluster.CoarseGrainedClusterMessages.UpdateDelegationTokens
import org.apache.spark.security.HadoopDelegationTokenProvider
Expand Down Expand Up @@ -76,28 +77,52 @@ private[spark] class HadoopDelegationTokenManager(
require((principal == null) == (keytab == null),
"Both principal and keytab must be defined, or neither.")

if (sparkConf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED)) {
val hasEncryption = sparkConf.getBoolean("spark.ssl.rpc.enabled", false) ||
(sparkConf.get(NETWORK_AUTH_ENABLED) &&
(sparkConf.get(NETWORK_CRYPTO_ENABLED) || sparkConf.get(SASL_ENCRYPTION_ENABLED)))
require(hasEncryption,
"RPC channel encryption must be enabled when " +
"spark.security.credentials.directProviders.enabled is true: either " +
"spark.ssl.rpc.enabled=true, or spark.authenticate=true together with " +
"spark.network.crypto.enabled or spark.authenticate.enableSaslEncryption. " +
"Credential tokens must not be transmitted over unencrypted channels.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Query: just want to make sure, we actually allow transmitting DT over unencrypted channels in a Kerberized case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I believe so. It is entirely possible for a user to have Kerberos enabled for auth but not have the rpc channel encrypted. I'm not entirely sure if there is another secure gate that Kerberos case enables but for non-Kerberos we definitely need to make sure that the transmission is over a secure channel.

}

private val delegationTokenProviders = loadProviders()
if (sparkConf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED) && delegationTokenProviders.isEmpty) {
logWarning("spark.security.credentials.directProviders.enabled is true but " +
"no HadoopDelegationTokenProvider implementations were discovered. " +
"Ensure provider JARs are on the classpath with META-INF/services registration.")
}
logDebug("Using the following builtin delegation token providers: " +
s"${delegationTokenProviders.keys.mkString(", ")}.")

private var renewalExecutor: ScheduledExecutorService = _

private def hasKerberosCredentials: Boolean =
sparkConf.get(KERBEROS_RENEWAL_CREDENTIALS) match {
case "keytab" => principal != null
case "ccache" => UserGroupInformation.getCurrentUser().hasKerberosCredentials()
case _ => false
}

/** @return Whether delegation token renewal is enabled. */
def renewalEnabled: Boolean = sparkConf.get(KERBEROS_RENEWAL_CREDENTIALS) match {
case "keytab" => principal != null
case "ccache" => UserGroupInformation.getCurrentUser().hasKerberosCredentials()
case _ => false
def renewalEnabled: Boolean = {
hasKerberosCredentials ||
(sparkConf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED) && delegationTokenProviders.nonEmpty)
}

/**
* Start the token renewer. Requires a principal and keytab. Upon start, the renewer will
* obtain delegation tokens for all configured services and send them to the driver, and
* set up tasks to periodically get fresh tokens as needed.
* Start the token renewer. Upon start, the renewer will obtain delegation tokens for all
* configured services and send them to the driver, and set up tasks to periodically get
* fresh tokens as needed.
*
* This method requires that a keytab has been provided to Spark, and will try to keep the
* logged in user's TGT valid while this manager is active.
* When Kerberos credentials are available, the manager keeps the TGT renewed and calls
* providers inside a privileged context. When only direct providers are configured, providers
* are called without Kerberos authentication.
*
* @return New set of delegation tokens created for the configured principal.
* @return New set of delegation tokens created for the configured services.
*/
def start(): Array[Byte] = {
require(renewalEnabled, "Token renewal must be enabled to start the renewer.")
Expand Down Expand Up @@ -141,39 +166,58 @@ private[spark] class HadoopDelegationTokenManager(
val hasKerberosCreds = principal != null ||
Option(currentUser.getRealUser()).getOrElse(currentUser).hasKerberosCredentials()

// Delegation tokens can only be obtained if the real user has Kerberos credentials, so
// skip creation when those are not available.
if (hasKerberosCreds) {
val freshUGI = doLogin()
freshUGI.doAs(new PrivilegedExceptionAction[Unit]() {
override def run(): Unit = {
val (newTokens, _) = obtainDelegationTokens()
val (newTokens, _, _) = obtainDelegationTokens()
creds.addAll(newTokens)
}
})
if (!currentUser.equals(freshUGI)) {
FileSystem.closeAllForUGI(freshUGI)
}
} else if (sparkConf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED)) {
val (newTokens, _, _) = obtainDelegationTokens(isolateFailures = true)
creds.addAll(newTokens)
}
}

/**
* Fetch new delegation tokens for configured services.
*
* @return 2-tuple (credentials with new tokens, time by which the tokens must be renewed)
* @param isolateFailures When true, catch per-provider exceptions and continue with remaining
* providers. When false, exceptions propagate to the caller.
* @return 3-tuple (credentials with new tokens, time by which the tokens must be renewed,
* number of providers that failed). The failure count is always 0 when
* isolateFailures is false (failures propagate instead).
*/
private def obtainDelegationTokens(): (Credentials, Long) = {
private def obtainDelegationTokens(
isolateFailures: Boolean = false): (Credentials, Long, Int) = {
val creds = new Credentials()
var failureCount = 0
val nextRenewal = delegationTokenProviders.values.flatMap { provider =>
if (provider.delegationTokensRequired(sparkConf, hadoopConf)) {
provider.obtainDelegationTokens(hadoopConf, sparkConf, creds)
if (isolateFailures) {
try {
provider.obtainDelegationTokens(hadoopConf, sparkConf, creds)
} catch {
case e: Exception =>
logWarning(log"Failed to obtain credentials from " +
log"${MDC(LogKeys.SERVICE_NAME, provider.serviceName)}.", e)
failureCount += 1
None
}
} else {
provider.obtainDelegationTokens(hadoopConf, sparkConf, creds)
}
} else {
logDebug(s"Service ${provider.serviceName} does not require a token." +
s" Check your configuration to see if security is disabled or not.")
None
}
}.foldLeft(Long.MaxValue)(math.min)
(creds, nextRenewal)
(creds, nextRenewal, failureCount)
}

// Visible for testing.
Expand All @@ -199,8 +243,7 @@ private[spark] class HadoopDelegationTokenManager(
*/
private def updateTokensTask(): Array[Byte] = {
try {
val freshUGI = doLogin()
val creds = obtainTokensAndScheduleRenewal(freshUGI)
val creds = obtainTokensAndScheduleRenewal()
val tokens = SparkHadoopUtil.get.serialize(creds)

logInfo("Updating delegation tokens.")
Expand All @@ -226,24 +269,44 @@ private[spark] class HadoopDelegationTokenManager(
*
* @return Credentials containing the new tokens.
*/
private def obtainTokensAndScheduleRenewal(ugi: UserGroupInformation): Credentials = {
ugi.doAs(new PrivilegedExceptionAction[Credentials]() {
override def run(): Credentials = {
val (creds, nextRenewal) = obtainDelegationTokens()

// Calculate the time when new credentials should be created, based on the configured
// ratio.
val now = System.currentTimeMillis
val ratio = sparkConf.get(CREDENTIALS_RENEWAL_INTERVAL_RATIO)
val delay = (ratio * (nextRenewal - now)).toLong
logInfo(log"Calculated delay on renewal is ${MDC(LogKeys.DELAY, delay)}," +
log" based on next renewal ${MDC(LogKeys.NEXT_RENEWAL_TIME, nextRenewal)}" +
log" and the ratio ${MDC(LogKeys.CREDENTIALS_RENEWAL_INTERVAL_RATIO, ratio)}," +
log" and current time ${MDC(LogKeys.CURRENT_TIME, now)}")
scheduleRenewal(delay)
creds
private def obtainTokensAndScheduleRenewal(): Credentials = {
val (creds, nextRenewal, failureCount) = if (hasKerberosCredentials) {
val freshUGI = doLogin()
val currentUser = UserGroupInformation.getCurrentUser()
val result = freshUGI.doAs(
new PrivilegedExceptionAction[(Credentials, Long, Int)]() {
override def run(): (Credentials, Long, Int) = {
obtainDelegationTokens()
}
})
if (!currentUser.equals(freshUGI)) {
FileSystem.closeAllForUGI(freshUGI)
}
})
result
} else if (sparkConf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED)) {
obtainDelegationTokens(isolateFailures = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finding 7. On this renewal path, obtainDelegationTokens(isolateFailures = true) catches every provider exception (line 199) and returns None for the failed provider. If all providers fail (e.g. the IdP is transiently unreachable at startup), nextRenewal folds to Long.MaxValue, so the delay computed just below (line 286) is effectively infinite and scheduleRenewal won't re-run for ~centuries. And since nothing throws, updateTokensTask's catch — which would otherwise reschedule after CREDENTIALS_RENEWAL_RETRY_WAIT — never fires. Net: executors receive empty credentials and there's no recovery short of a driver restart. The Kerberos path avoids this because it doesn't isolate, so a failure propagates and gets retried.

Note nextRenewal == Long.MaxValue is also the legitimate "providers succeeded but reported no expiry" case, so keying a retry off the delay alone would over-fire. Cleaner to have the isolate path signal that a failure actually occurred (e.g. count the caught exceptions) and, when that count is > 0, schedule CREDENTIALS_RENEWAL_RETRY_WAIT instead of the computed delay.

} else {
// Reachable when ccache-based Kerberos credentials expired after start().
// Throw so that updateTokensTask() reschedules a retry instead of silently
// distributing empty credentials and never renewing again.
throw new IllegalStateException("Cannot obtain delegation tokens: no Kerberos " +
"credentials are available and direct credential providers are not enabled.")
}

// Scheduling does not require a privileged context (only token acquisition does).
val now = System.currentTimeMillis
val ratio = sparkConf.get(CREDENTIALS_RENEWAL_INTERVAL_RATIO)
val delay = if (failureCount > 0 && nextRenewal == Long.MaxValue) {
TimeUnit.SECONDS.toMillis(sparkConf.get(CREDENTIALS_RENEWAL_RETRY_WAIT))
} else {
(ratio * (nextRenewal - now)).toLong
}
logInfo(log"Calculated delay on renewal is ${MDC(LogKeys.DELAY, delay)}," +
log" based on next renewal ${MDC(LogKeys.NEXT_RENEWAL_TIME, nextRenewal)}" +
log" and the ratio ${MDC(LogKeys.CREDENTIALS_RENEWAL_INTERVAL_RATIO, ratio)}," +
log" and current time ${MDC(LogKeys.CURRENT_TIME, now)}")
scheduleRenewal(delay)
creds
}

private def doLogin(): UserGroupInformation = {
Expand Down
12 changes: 12 additions & 0 deletions core/src/main/scala/org/apache/spark/internal/config/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1690,6 +1690,18 @@ package object config {
.timeConf(TimeUnit.SECONDS)
.createWithDefaultString("1h")

private[spark] val CREDENTIALS_DIRECT_PROVIDERS_ENABLED =
ConfigBuilder("spark.security.credentials.directProviders.enabled")
.doc(
"When true, enables delegation token collection and renewal without Kerberos. " +
"Providers are called directly (without doLogin/doAs) and participate in the " +
"same renewal and distribution lifecycle as Kerberos delegation token providers. " +
"Providers that require Kerberos self-gate via delegationTokensRequired.")
.version("4.3.0")
.withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
.booleanConf
.createWithDefault(false)

private[spark] val SHUFFLE_SORT_INIT_BUFFER_SIZE =
ConfigBuilder("spark.shuffle.sort.initialBufferSize")
.internal()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ private[spark] trait SupportsDelegationToken {
/**
* Create the delegation token manager to be used for the application. This method is called
* once during the start of the scheduler backend (so after the object has already been
* fully constructed), only if security is enabled in the Hadoop configuration.
* fully constructed), when security is enabled or direct credential providers are configured.
*/
protected def createTokenManager(): Option[HadoopDelegationTokenManager] = None

Expand All @@ -42,8 +42,14 @@ private[spark] trait SupportsDelegationToken {
*/
protected def updateDelegationTokens(tokens: Array[Byte]): Unit

/**
* Whether the token manager should be started. Returns true if Hadoop security is enabled
* or if direct credential providers are configured.
*/
protected def tokenManagerRequired(): Boolean = UserGroupInformation.isSecurityEnabled

protected def setupTokenManager(): Unit = {
if (UserGroupInformation.isSecurityEnabled) {
if (tokenManagerRequired()) {
delegationTokenManager = createTokenManager()
delegationTokenManager.foreach { dtm =>
val ugi = UserGroupInformation.getCurrentUser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@ class CoarseGrainedSchedulerBackend(scheduler: TaskSchedulerImpl, val rpcEnv: Rp

override def start(): Unit = {
setupTokenManager()
if (conf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED) && delegationTokenManager.isEmpty) {
logWarning("spark.security.credentials.directProviders.enabled is set but " +
"this cluster manager does not support credential distribution. " +
"No tokens will be collected or renewed.")
}
}

override protected def tokenManagerRequired(): Boolean = {
super.tokenManagerRequired() || conf.get(CREDENTIALS_DIRECT_PROVIDERS_ENABLED)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This makes tokenManagerRequired() return true for every CoarseGrainedSchedulerBackend subclass, but only YARN, Kubernetes, and local mode actually override createTokenManager(). On standalone, StandaloneSchedulerBackend inherits the default createTokenManager(): None, so enabling spark.security.credentials.directProviders.enabled there passes the encryption require, flips tokenManagerRequired() to true — and then setupTokenManager() silently does nothing. No tokens are collected, no warning is logged, and the user has no signal that the feature is inert on their cluster manager.

  1. Please document in the PR description (and documentation) that StandaloneCluster is not supported (or out of scope).
  2. Log a warning in setupTokenManager() (or here) when the config is enabled but createTokenManager() returns None, so make the misconfiguration visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Logging a warning now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the documentation and PR description. Also, added followup JIRA: https://issues.apache.org/jira/browse/SPARK-58329

}

protected def createDriverEndpoint(): DriverEndpoint = new DriverEndpoint()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ private[spark] class LocalSchedulerBackend(
Some(new HadoopDelegationTokenManager(conf, scheduler.sc.hadoopConfiguration, localEndpoint))
}

override protected def tokenManagerRequired(): Boolean = {
super.tokenManagerRequired() || conf.get(config.CREDENTIALS_DIRECT_PROVIDERS_ENABLED)
}

override protected def updateDelegationTokens(tokens: Array[Byte]): Unit = {
SparkHadoopUtil.get.addDelegationTokens(tokens, conf)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@
#

org.apache.spark.deploy.security.ExceptionThrowingDelegationTokenProvider
org.apache.spark.deploy.security.TestNonKerberosTokenProvider
org.apache.spark.deploy.security.TestDisabledProvider
org.apache.spark.deploy.security.TestFailingProvider
Loading