This is an automated email from the ASF dual-hosted git repository.
pan3793 pushed a commit to branch branch-3.5
in repository https://gitbox.apache.org/repos/asf/hadoop.git
The following commit(s) were added to refs/heads/branch-3.5 by this push:
new 0d84c1ae126 HADOOP-19906. Restore Subject propagation on JDK 22+ via
InheritableThreadLocal cascade in SubjectUtil (#8522)
0d84c1ae126 is described below
commit 0d84c1ae1264b0fbb5aa66f1c8101c88111f395d
Author: Cheng Pan <[email protected]>
AuthorDate: Tue Jul 7 11:04:13 2026 +0800
HADOOP-19906. Restore Subject propagation on JDK 22+ via
InheritableThreadLocal cascade in SubjectUtil (#8522)
Reviewed-by: Steve Loughran <[email protected]>
Signed-off-by: Cheng Pan <[email protected]>
---
.../security/authentication/util/SubjectUtil.java | 100 ++++++++++-
.../util/TestSubjectPropagation.java | 184 +++++++++++++++++++++
2 files changed, 279 insertions(+), 5 deletions(-)
diff --git
a/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/SubjectUtil.java
b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/SubjectUtil.java
index c94f554e3dc..c65a7d67610 100644
---
a/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/SubjectUtil.java
+++
b/hadoop-common-project/hadoop-auth/src/main/java/org/apache/hadoop/security/authentication/util/SubjectUtil.java
@@ -31,6 +31,8 @@
import javax.security.auth.Subject;
import org.apache.hadoop.classification.InterfaceAudience.Private;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* An utility class that adapts the Security Manager and APIs related to it for
@@ -49,6 +51,7 @@
*/
@Private
public final class SubjectUtil {
+ private static final Logger LOG = LoggerFactory.getLogger(SubjectUtil.class);
private static final MethodHandle CALL_AS = lookupCallAs();
static final boolean HAS_CALL_AS = CALL_AS != null;
private static final MethodHandle DO_AS = HAS_CALL_AS ? null : lookupDoAs();
@@ -66,6 +69,39 @@ public final class SubjectUtil {
*/
public static final boolean THREAD_INHERITS_SUBJECT =
checkThreadInheritsSubject();
+ /**
+ * Hadoop-managed {@link InheritableThreadLocal} that mirrors the active
Subject for the
+ * duration of {@link #callAs} / {@link #doAs}. On JDK 22+ the JVM stopped
propagating the
+ * Subject to newly-constructed Threads via {@code AccessControlContext}
(JEP 411 / 486),
+ * and the new {@code ScopedValue}-based mechanism behind {@code
Subject.current()} also
+ * does not inherit across {@code Thread.<init>}. Maintaining our own
InheritableThreadLocal
+ * lets {@link #current()} restore the pre-JDK22 transitive cascade for any
platform Thread
+ * regardless of class hierarchy (plain {@code Thread}, {@code
ForkJoinWorkerThread}, Netty
+ * {@code FastThreadLocalThread}, etc.) — including Threads created by
3rd-party factories
+ * we don't own. A child Thread copies its parent's value at {@code
Thread.<init>} time and
+ * keeps it for its entire lifetime, matching the historical {@code
inheritedAccessControlContext}
+ * behaviour.
+ *
+ * <p>Only consulted when {@link #THREAD_INHERITS_SUBJECT} is {@code false};
on older JDKs the
+ * JVM still does the propagation natively and we don't need this layer.
+ *
+ * <p>Limitations:
+ * <ul>
+ * <li>Virtual threads created via {@code Thread.ofVirtual().start(...)}
default to
+ * {@code inheritThreadLocals=false} and so will <em>not</em> see the
value. To restore
+ * inheritance for them, build with {@code
.inheritInheritableThreadLocals(true)}, or
+ * fork via {@code StructuredTaskScope} which propagates {@code
ScopedValue} instead.
+ * Hadoop and the typical Spark / HBase / HiveServer2 deployments use
platform threads,
+ * so this caveat does not apply today.</li>
+ * <li>Code that calls {@code Subject.current()} directly (i.e. not via
{@link #current()})
+ * on an inherited thread will still see {@code null}. Hadoop / UGI
consistently use
+ * {@link #current()} so this is only a concern for downstream
applications that bypass
+ * Hadoop.</li>
+ * </ul>
+ */
+ private static final InheritableThreadLocal<Subject> CURRENT_SUBJECT_TL =
+ THREAD_INHERITS_SUBJECT ? null : new InheritableThreadLocal<>();
+
/**
* Try to return the method handle for Subject#callAs()
*
@@ -98,10 +134,10 @@ private static boolean checkThreadInheritsSubject() {
} else {
// 24+ never inherits the Subject.
// For 22 and 23 the behavior actually depends on whether the
SecurityManager
- // is enabled, but this check is only used to determine whether a
doAs/callAs
- // call can be optimized out in SubjectInheritingThread and Daemon.
- // We accept that possible minor performance cost for those EOL non-LTS
versions
- // to avoid the extra complexity and to prevent the JVM from logging
+ // is enabled, but this check is only used to gate maintenance of our own
+ // InheritableThreadLocal cascade ({@link #CURRENT_SUBJECT_TL}). We
accept
+ // that possible minor performance cost for those EOL non-LTS versions to
+ // avoid the extra complexity and to prevent the JVM from logging
// SecurityManager warnings to the console.
return false;
}
@@ -225,6 +261,14 @@ private static MethodHandle lookupGetContext() {
/**
* Map to Subject.callAs() if available, otherwise maps to Subject.doAs().
*
+ * <p>On JDK 22+ this method also maintains a Hadoop-managed
+ * {@link InheritableThreadLocal} ({@link #CURRENT_SUBJECT_TL}) for the
duration of
+ * {@code action}, so that any platform Thread constructed inside {@code
action} inherits
+ * the {@code subject} and a subsequent {@link #current()} call on the child
sees it. This
+ * is what restores the pre-JDK22 transitive Subject cascade for plain
{@link Thread} and
+ * any third-party {@code Thread} subclass without requiring a
Hadoop-specific Thread
+ * subclass.
+ *
* @param subject the subject this action runs as
* @param action the action to run
* @return the result of the action
@@ -234,9 +278,31 @@ private static MethodHandle lookupGetContext() {
* The cause of the {@code CompletionException} is set to the exception
* thrown by {@code action.call()}.
*/
- @SuppressWarnings("unchecked")
public static <T> T callAs(Subject subject, Callable<T> action) throws
CompletionException {
Objects.requireNonNull(action);
+ if (THREAD_INHERITS_SUBJECT) {
+ return invokeCallAs(subject, action);
+ }
+ final Subject prev = CURRENT_SUBJECT_TL.get();
+ if (subject == null) {
+ CURRENT_SUBJECT_TL.remove();
+ } else if (subject != prev) {
+ CURRENT_SUBJECT_TL.set(subject);
+ }
+ try {
+ return invokeCallAs(subject, action);
+ } finally {
+ if (prev == null) {
+ CURRENT_SUBJECT_TL.remove();
+ } else if (prev != subject) {
+ CURRENT_SUBJECT_TL.set(prev);
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> T invokeCallAs(Subject subject, Callable<T> action)
+ throws CompletionException {
if (HAS_CALL_AS) {
try {
return (T) CALL_AS.invoke(subject, action);
@@ -335,9 +401,33 @@ public static <T> T doAs(
/**
* Maps to Subject.current() if available, otherwise maps to
Subject.getSubject().
*
+ * <p>On JDK 22+ also consults the Hadoop-managed
+ * {@link #CURRENT_SUBJECT_TL InheritableThreadLocal} so that platform
Threads which
+ * inherited a Subject from a parent's {@link #callAs} scope continue to
observe it.
+ * The JDK API {@code Subject.current()} (backed by {@code ScopedValue}) is
consulted
+ * first, so any future virtual-thread / {@code StructuredTaskScope} usage
that propagates
+ * the {@code ScopedValue} keeps working without falling back to the
ThreadLocal layer.
+ *
* @return the current subject
*/
public static Subject current() {
+ if (!THREAD_INHERITS_SUBJECT) {
+ // Prefer the JDK ScopedValue source of truth (forward-compatible with
virtual threads
+ // forked through StructuredTaskScope, which DO propagate ScopedValue).
+ Subject fromJdk = invokeJdkCurrent();
+ if (fromJdk != null) {
+ LOG.trace("Get current Subject from JDK API directly");
+ return fromJdk;
+ }
+ // Fallback: the Hadoop InheritableThreadLocal cascade for platform
Threads that inherited
+ // a Subject at construction time but are no longer inside any callAs
scope themselves.
+ LOG.trace("Get current Subject from Hadoop-managed
InheritableThreadLocal");
+ return CURRENT_SUBJECT_TL.get();
+ }
+ return invokeJdkCurrent();
+ }
+
+ private static Subject invokeJdkCurrent() {
try {
return (Subject) CURRENT.invoke();
} catch (Throwable t) {
diff --git
a/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestSubjectPropagation.java
b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestSubjectPropagation.java
new file mode 100644
index 00000000000..c8daeb23c48
--- /dev/null
+++
b/hadoop-common-project/hadoop-auth/src/test/java/org/apache/hadoop/security/authentication/util/TestSubjectPropagation.java
@@ -0,0 +1,184 @@
+/**
+ * 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.hadoop.security.authentication.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.security.auth.Subject;
+
+/**
+ * Verifies the JDK22+ Subject-propagation cascade restored by the
+ * {@link InheritableThreadLocal}-based mechanism in
+ * {@link SubjectUtil#callAs(Subject, java.util.concurrent.Callable)}.
+ *
+ * <p>The mechanism relies solely on Hadoop's own InheritableThreadLocal layer
and
+ * the JVM's standard {@code Thread.<init>}-time {@code
InheritableThreadLocal} copy.
+ * No special {@code Thread} subclass is required: any platform thread ({@link
Thread},
+ * {@link java.util.concurrent.ForkJoinWorkerThread}, Netty's
+ * {@code FastThreadLocalThread}, …) constructed inside a {@code
SubjectUtil.callAs}
+ * scope inherits the active Subject and observes it via {@link
SubjectUtil#current()}.
+ */
+public class TestSubjectPropagation {
+
+ /** Plain Thread inside callAs sees parent's Subject via
SubjectUtil.current(). */
+ @Test
+ public void testPlainThreadInheritsSubjectViaSubjectUtilCallAs() {
+ Subject parent = new Subject();
+ AtomicReference<Subject> seen = new AtomicReference<>();
+ SubjectUtil.callAs(parent, () -> {
+ Thread t = new Thread(() -> seen.set(SubjectUtil.current()),
"plain-child");
+ t.start();
+ t.join(50000);
+ return null;
+ });
+ assertThat(seen.get()).as(
+ "plain Thread inside callAs must see parent's Subject")
+ .isEqualTo(parent);
+ }
+
+ /**
+ * Plain Thread submitted to a {@link
java.util.concurrent.ThreadPoolExecutor} inside callAs
+ * sees parent's Subject. Mimics the ubiquitous Spark / HiveServer2 /
generic long-running JVM
+ * pattern of submitting work into a long-lived pool from inside a UGI
{@code doAs} scope.
+ */
+ @Test
+ public void testPlainThreadInThreadPoolExecutorInheritsSubject() throws
Exception {
+ Subject parent = new Subject();
+ AtomicReference<Subject> seen = new AtomicReference<>();
+ ExecutorService pool = Executors.newFixedThreadPool(2, r -> new Thread(r,
"plain-pool"));
+ try {
+ SubjectUtil.callAs(parent, () -> {
+ pool.submit(() -> seen.set(SubjectUtil.current()))
+ .get(5, TimeUnit.SECONDS);
+ return null;
+ });
+ } finally {
+ pool.shutdownNow();
+ pool.awaitTermination(5, TimeUnit.SECONDS);
+ }
+ assertThat(seen.get()).as(
+ "pool worker must see parent's Subject via InheritableThreadLocal
cascade")
+ .isEqualTo(parent);
+ }
+
+ /**
+ * Transitive cascade: pool worker created inside callAs scope inherits the
Subject
+ * permanently; even after the original callAs scope exits, the worker still
sees the
+ * Subject and propagates it to any child Thread it creates. Matches
pre-JDK22
+ * {@code inheritedAccessControlContext} cascading semantics.
+ */
+ @Test
+ public void testTransitiveCascadeViaPlainPoolAfterCallAsExits() throws
Exception {
+ Subject parent = new Subject();
+ AtomicReference<Subject> seen = new AtomicReference<>();
+ ExecutorService pool = Executors.newFixedThreadPool(1, r -> new Thread(r,
"plain-pool"));
+ try {
+ // Step 1: create the worker INSIDE the callAs scope so it inherits the
Subject's ThreadLocal.
+ SubjectUtil.callAs(parent, () -> {
+ pool.submit(() -> { /* warm worker, no-op */ }).get(5,
TimeUnit.SECONDS);
+ return null;
+ });
+ // Step 2: callAs has exited. Submit a task that itself spawns a
grandchild Thread,
+ // OUTSIDE any callAs scope. The grandchild must still see the parent's
Subject — this
+ // is the "permanent inherited context" semantic that pre-JDK22 had via
+ // inheritedAccessControlContext.
+ pool.submit(() -> {
+ Thread grandchild = new Thread(() -> seen.set(SubjectUtil.current()),
"plain-grandchild");
+ grandchild.start();
+ try {
+ grandchild.join(5_000);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ }).get(5, TimeUnit.SECONDS);
+ } finally {
+ pool.shutdownNow();
+ pool.awaitTermination(5, TimeUnit.SECONDS);
+ }
+ assertThat(seen.get()).as(
+ "Subject must cascade transitively even after the original callAs
scope has exited")
+ .isEqualTo(parent);
+ }
+
+ @Test
+ public void testNestedCallAsRestoresOuterSubject() {
+ Subject s1 = new Subject();
+ Subject s2 = new Subject();
+ AtomicReference<Subject> insideInner = new AtomicReference<>();
+ AtomicReference<Subject> insideOuterAfterInner = new AtomicReference<>();
+ SubjectUtil.callAs(s1, () -> {
+ SubjectUtil.callAs(s2, () -> {
+ insideInner.set(SubjectUtil.current());
+ return null;
+ });
+ insideOuterAfterInner.set(SubjectUtil.current());
+ return null;
+ });
+ assertThat(insideInner.get()).as("inner callAs must shadow outer
Subject").isEqualTo(s2);
+ assertThat(insideOuterAfterInner.get()).as(
+ "outer Subject must be restored after the inner callAs
exits").isEqualTo(s1);
+ }
+
+ @Test
+ public void testCallAsClearsTlStateOnExit() {
+ Subject s = new Subject();
+ SubjectUtil.callAs(s, () -> null);
+ assertThat(SubjectUtil.current()).as(
+ "after callAs exits, current() must return null, not stale ThreadLocal
state")
+ .isNull();
+ }
+
+ @Test
+ public void testCallAsWithNullSubject() {
+ Subject parent = new Subject();
+ AtomicReference<Subject> seen = new AtomicReference<>();
+ SubjectUtil.callAs(parent, () -> {
+ // Nested callAs(null, ...) must produce a null current Subject inside
the inner action.
+ SubjectUtil.callAs(null, () -> {
+ seen.set(SubjectUtil.current());
+ return null;
+ });
+ return null;
+ });
+ assertThat(seen.get()).as(
+ "callAs(null, ...) inside outer callAs must produce null current
Subject")
+ .isNull();
+ }
+
+ @Test
+ public void testCallAsWithSameSubjectIsNoOpInTlState() {
+ // Verify nesting with the same Subject doesn't disturb the prev/restore
logic.
+ Subject s = new Subject();
+ AtomicReference<Subject> after = new AtomicReference<>();
+ SubjectUtil.callAs(s, () -> {
+ SubjectUtil.callAs(s, () -> null);
+ after.set(SubjectUtil.current());
+ return null;
+ });
+ assertThat(after.get()).as(
+ "Re-entering callAs with the same Subject must keep current()
returning that Subject")
+ .isEqualTo(s);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]