diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java b/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java
index 083c7d30f7..78529ac82c 100644
--- a/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java
+++ b/jmix-core/core/src/main/java/io/jmix/core/security/SecurityContextHelper.java
@@ -23,6 +23,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.jspecify.annotations.Nullable;
@@ -44,6 +45,10 @@ public static Authentication getAuthentication() {
/**
* Sets the Authentication in the current {@link SecurityContext}.
+ *
+ * Note that this method modifies the current {@link SecurityContext} instance, which may be shared with other
+ * threads of the same HTTP session. To execute code on behalf of another user for a limited time,
+ * use {@link SystemAuthenticator} instead.
*/
public static void setAuthentication(@Nullable Authentication authentication) {
if (authentication != null) {
@@ -54,4 +59,47 @@ public static void setAuthentication(@Nullable Authentication authentication) {
LogMdc.setup(null);
}
}
+
+ /**
+ * Makes the given context current for this thread only, without modifying the previously current context
+ * instance. If the installed {@link SecurityContextHolderStrategy} supports {@link ThreadSecurityContextOverride},
+ * the context takes precedence over other sources such as the Vaadin session. Also sets up the logging MDC.
+ *
+ * Must be paired with {@link #restoreContext(SecurityContext)} in a "finally" block:
+ *
+ * SecurityContext previous = SecurityContextHolder.getContext();
+ * SecurityContextHelper.installContext(context);
+ * try {
+ * // ...
+ * } finally {
+ * SecurityContextHelper.restoreContext(previous);
+ * }
+ *
+ */
+ public static void installContext(SecurityContext context) {
+ SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
+ if (strategy instanceof ThreadSecurityContextOverride override) {
+ override.pushContext(context);
+ } else {
+ strategy.setContext(context);
+ }
+ LogMdc.setup(context.getAuthentication());
+ }
+
+ /**
+ * Reverts the matching {@link #installContext(SecurityContext)} call.
+ *
+ * @param previous the context that was current before {@code installContext()}, or null to clear the context
+ */
+ public static void restoreContext(@Nullable SecurityContext previous) {
+ SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
+ if (strategy instanceof ThreadSecurityContextOverride override) {
+ override.popContext();
+ } else if (previous != null) {
+ strategy.setContext(previous);
+ } else {
+ strategy.clearContext();
+ }
+ LogMdc.setup(previous != null ? previous.getAuthentication() : null);
+ }
}
diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java b/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java
new file mode 100644
index 0000000000..6e93b9507b
--- /dev/null
+++ b/jmix-core/core/src/main/java/io/jmix/core/security/ThreadSecurityContextOverride.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 io.jmix.core.security;
+
+import org.jspecify.annotations.NullMarked;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+
+/**
+ * Optional capability of a {@link SecurityContextHolderStrategy}: lets the current thread temporarily override the
+ * {@link SecurityContext} returned by {@link SecurityContextHolder#getContext()}, regardless of where the strategy
+ * would otherwise take the context from (for example, from the current Vaadin session).
+ *
+ * Used by {@link SystemAuthenticator} to make {@code begin()}/{@code end()} affect the current thread only.
+ * If the installed strategy does not implement this interface, {@link SystemAuthenticator} falls back to
+ * {@link SecurityContextHolder#setContext(SecurityContext)}, which is sufficient for a plain thread-local strategy.
+ *
+ * Implementations must keep a per-thread stack so that nested overrides work.
+ * {@link SecurityContextHolderStrategy#setContext(SecurityContext)} must replace the current override without
+ * discarding the enclosing scopes. {@link SecurityContextHolderStrategy#clearContext()} must clear the stack
+ * when a request or task releases its thread.
+ */
+@NullMarked
+public interface ThreadSecurityContextOverride {
+
+ /**
+ * Makes the given context the one returned by {@code getContext()} on the current thread until a matching
+ * {@link #popContext()} call.
+ */
+ void pushContext(SecurityContext context);
+
+ /**
+ * Removes the most recently pushed override from the current thread. Does nothing if there is no override.
+ */
+ void popContext();
+}
diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java
index 51ec11f4bf..14e6a52bca 100644
--- a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java
+++ b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorImpl.java
@@ -18,7 +18,6 @@
import com.google.common.base.Strings;
import io.jmix.core.JmixOrder;
-import io.jmix.core.impl.logging.LogMdc;
import io.jmix.core.security.SecurityContextHelper;
import io.jmix.core.security.SystemAuthenticationToken;
import io.jmix.core.security.SystemAuthenticator;
@@ -31,6 +30,8 @@
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.jspecify.annotations.Nullable;
@@ -65,7 +66,10 @@ public Authentication begin(@Nullable String login) {
throw new IllegalStateException("AuthenticationManager is not defined");
}
- pushAuthentication(SecurityContextHelper.getAuthentication());
+ // The previous context is saved as an instance and never modified: it may be shared with other threads
+ // of the same HTTP session.
+ SecurityContext previous = SecurityContextHolder.getContext();
+ pushSecurityContext(previous);
try {
Authentication authentication;
@@ -79,12 +83,14 @@ public Authentication begin(@Nullable String login) {
authentication = authenticationManager.authenticate(authToken);
}
- SecurityContextHelper.setAuthentication(authentication);
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(authentication);
+ SecurityContextHelper.installContext(context);
return authentication;
} catch (AuthenticationException e) {
- pollAuthentication();
+ pollSecurityContext();
throw e;
}
}
@@ -96,10 +102,9 @@ public Authentication begin() {
@Override
public void end() {
- log.trace("Set previous Authentication");
- Authentication previous = pollAuthentication();
- SecurityContextHelper.setAuthentication(previous);
- LogMdc.setup(previous);
+ log.trace("Set previous SecurityContext");
+ SecurityContext previous = pollSecurityContext();
+ SecurityContextHelper.restoreContext(previous);
}
@Override
diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java
index 879c39c565..3959116a70 100644
--- a/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java
+++ b/jmix-core/core/src/main/java/io/jmix/core/security/impl/SystemAuthenticatorSupport.java
@@ -16,28 +16,31 @@
package io.jmix.core.security.impl;
-import io.jmix.core.security.SystemAuthenticationToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
import org.jspecify.annotations.Nullable;
import java.util.ArrayDeque;
import java.util.Deque;
+/**
+ * Keeps a per-thread stack of {@link SecurityContext} instances that were current before each
+ * {@code begin()} call, so that {@code end()} can reinstall the exact previous instance.
+ *
+ * The stored instances are never modified.
+ */
public abstract class SystemAuthenticatorSupport {
private static final Logger log = LoggerFactory.getLogger(SystemAuthenticatorSupport.class);
- protected static final Authentication NULL_AUTHENTICATION = new NullAuthentication();
-
- protected ThreadLocal> threadLocalStack = new ThreadLocal<>();
+ protected ThreadLocal> threadLocalStack = new ThreadLocal<>();
public SystemAuthenticatorSupport() {
}
- protected void pushAuthentication(@Nullable Authentication authentication) {
- Deque stack = threadLocalStack.get();
+ protected void pushSecurityContext(SecurityContext securityContext) {
+ Deque stack = threadLocalStack.get();
if (stack == null) {
stack = new ArrayDeque<>();
threadLocalStack.set(stack);
@@ -46,24 +49,16 @@ protected void pushAuthentication(@Nullable Authentication authentication) {
log.warn("Stack is too big: {}. Check correctness of begin/end invocations.", stack.size());
}
}
- if (authentication == null) {
- stack.push(NULL_AUTHENTICATION);
- } else {
- stack.push(authentication);
- }
+ stack.push(securityContext);
}
@Nullable
- protected Authentication pollAuthentication() {
- Deque stack = threadLocalStack.get();
+ protected SecurityContext pollSecurityContext() {
+ Deque stack = threadLocalStack.get();
if (stack != null) {
- Authentication authentication = stack.poll();
- if (authentication != null) {
- if (authentication == NULL_AUTHENTICATION) {
- return null;
- } else {
- return authentication;
- }
+ SecurityContext securityContext = stack.poll();
+ if (securityContext != null) {
+ return securityContext;
} else {
log.warn("Stack is empty. Check correctness of begin/end invocations.");
}
@@ -72,14 +67,4 @@ protected Authentication pollAuthentication() {
}
return null;
}
-
- protected static class NullAuthentication extends SystemAuthenticationToken {
-
- private static final long serialVersionUID = 5437664860036209641L;
-
- public NullAuthentication() {
- super();
- }
- }
-
}
diff --git a/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy b/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy
index 5f66f01309..0ce7027428 100644
--- a/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy
+++ b/jmix-core/core/src/test/groovy/security/SystemAuthenticatorTest.groovy
@@ -21,7 +21,9 @@ import io.jmix.core.security.InMemoryUserRepository
import io.jmix.core.security.SystemAuthenticationToken
import io.jmix.core.security.SystemAuthenticator
import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
@@ -29,6 +31,10 @@ import org.springframework.test.context.ContextConfiguration
import spock.lang.Specification
import test_support.base.TestBaseConfiguration
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
@ContextConfiguration(classes = [CoreConfiguration, TestBaseConfiguration])
class SystemAuthenticatorTest extends Specification {
@@ -139,4 +145,82 @@ class SystemAuthenticatorTest extends Specification {
SecurityContextHolder.getContext().getAuthentication() == null
}
+
+ def "begin and end do not modify the previously current SecurityContext instance"() {
+ given: "a context of a logged-in user is current, as on a UI request thread"
+ Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities)
+ SecurityContext original = SecurityContextHolder.createEmptyContext()
+ original.setAuthentication(adminAuth)
+ SecurityContextHolder.setContext(original)
+
+ when:
+ authenticator.begin()
+
+ then: "the current thread sees system, but the original context object is untouched"
+ SecurityContextHolder.getContext().getAuthentication() instanceof SystemAuthenticationToken
+ original.getAuthentication().is(adminAuth)
+
+ when:
+ authenticator.end()
+
+ then: "the original context instance is current again"
+ SecurityContextHolder.getContext().is(original)
+ SecurityContextHolder.getContext().getAuthentication().is(adminAuth)
+
+ cleanup:
+ SecurityContextHolder.clearContext()
+ }
+
+ def "begin on one thread is not visible to another thread sharing the same SecurityContext instance"() {
+ given: "two threads hold the same context instance, as a UI thread and an async task do"
+ Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities)
+ SecurityContext shared = SecurityContextHolder.createEmptyContext()
+ shared.setAuthentication(adminAuth)
+ SecurityContextHolder.setContext(shared)
+ ExecutorService otherThread = Executors.newSingleThreadExecutor()
+ otherThread.submit { SecurityContextHolder.setContext(shared) }.get(5, TimeUnit.SECONDS)
+
+ when:
+ authenticator.begin()
+ Authentication seenByOtherThread = otherThread.submit {
+ SecurityContextHolder.getContext().getAuthentication()
+ }.get(5, TimeUnit.SECONDS)
+
+ then:
+ seenByOtherThread.is(adminAuth)
+
+ cleanup:
+ authenticator.end()
+ otherThread.shutdownNow()
+ SecurityContextHolder.clearContext()
+ }
+
+ def "overlapping begin and end on two threads leave the shared SecurityContext unchanged"() {
+ given:
+ Authentication adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities)
+ SecurityContext shared = SecurityContextHolder.createEmptyContext()
+ shared.setAuthentication(adminAuth)
+ SecurityContextHolder.setContext(shared)
+ ExecutorService otherThread = Executors.newSingleThreadExecutor()
+ otherThread.submit { SecurityContextHolder.setContext(shared) }.get(5, TimeUnit.SECONDS)
+
+ when: "T1 begins, T2 begins, T1 ends, T2 ends"
+ authenticator.begin()
+ otherThread.submit { authenticator.begin() }.get(5, TimeUnit.SECONDS)
+ authenticator.end()
+ otherThread.submit { authenticator.end() }.get(5, TimeUnit.SECONDS)
+
+ Authentication seenByOtherThread = otherThread.submit {
+ SecurityContextHolder.getContext().getAuthentication()
+ }.get(5, TimeUnit.SECONDS)
+
+ then:
+ shared.getAuthentication().is(adminAuth)
+ SecurityContextHolder.getContext().getAuthentication().is(adminAuth)
+ seenByOtherThread.is(adminAuth)
+
+ cleanup:
+ otherThread.shutdownNow()
+ SecurityContextHolder.clearContext()
+ }
}
diff --git a/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java b/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java
index 77d6d4b8ad..2093aa9aac 100644
--- a/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java
+++ b/jmix-flowui/flowui-starter/src/main/java/io/jmix/autoconfigure/flowui/FlowuiAutoConfiguration.java
@@ -23,6 +23,7 @@
import io.jmix.flowui.component.groupgrid.adapter.GroupDataGridAdapterFactory;
import io.jmix.flowui.component.groupgrid.adapter.GroupDataGridAdapterProvider;
import io.jmix.flowui.sys.ActionsConfiguration;
+import io.jmix.flowui.sys.JmixSecurityContextHolderStrategy;
import io.jmix.flowui.sys.UiAccessChecker;
import io.jmix.flowui.sys.ViewControllersConfiguration;
import io.jmix.flowui.sys.ViewSupport;
@@ -38,11 +39,13 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Scope;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.jspecify.annotations.Nullable;
import java.util.Collections;
@@ -50,9 +53,23 @@
@AutoConfiguration
+// Must be processed before Vaadin's SpringSecurityAutoConfiguration, so that its @ConditionalOnMissingBean
+// SecurityContextHolderStrategy backs off in favor of the Jmix one defined below.
+@AutoConfigureBefore(name = "com.vaadin.flow.spring.SpringSecurityAutoConfiguration")
@Import({CoreConfiguration.class, FlowuiConfiguration.class})
public class FlowuiAutoConfiguration {
+ /**
+ * Replaces Vaadin's {@code VaadinAwareSecurityContextHolderStrategy} with a wrapper that lets
+ * {@link io.jmix.core.security.SystemAuthenticator} override the security context for the current thread only.
+ * Vaadin's {@code SpringSecurityAutoConfiguration} installs this bean into {@code SecurityContextHolder}.
+ */
+ @Bean("flowui_SecurityContextHolderStrategy")
+ @ConditionalOnMissingBean(SecurityContextHolderStrategy.class)
+ public SecurityContextHolderStrategy securityContextHolderStrategy() {
+ return new JmixSecurityContextHolderStrategy();
+ }
+
@Bean("jmix_AppUiControllers")
@ConditionalOnMissingBean(name = "jmix_AppUiControllers")
public ViewControllersConfiguration viewControllersConfiguration(
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java
index 5ace177382..f63a905b6c 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiEventPublisher.java
@@ -21,9 +21,11 @@
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.VaadinSessionState;
+import com.vaadin.flow.server.WrappedSession;
import io.jmix.core.cluster.ClusterApplicationEvent;
import io.jmix.core.cluster.ClusterApplicationEventPublisher;
import io.jmix.core.security.CurrentAuthentication;
+import io.jmix.core.security.SecurityContextHelper;
import io.jmix.core.security.SystemAuthenticator;
import io.jmix.core.usersubstitution.CurrentUserSubstitution;
import io.jmix.flowui.sys.SessionHolder;
@@ -34,6 +36,9 @@
import org.springframework.context.ApplicationEvent;
import org.springframework.context.event.EventListener;
import org.jspecify.annotations.Nullable;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Component;
import java.util.*;
@@ -96,21 +101,50 @@ protected void sendEventToUserSessions(ApplicationEvent event, Map> usernameSessionEntry : userSessions.entrySet()) {
- // Without 'VaadinAwareSecurityContextHolderStrategyConfiguration' configuration
- // when we get access to another user session, the security context is still the same as
- // in VaadinSession of sender user. I.e. if "admin" send notification to "user1", the
- // "CurrentAuthentication#getUser()" under VaadinSession of "user1" will return "admin".
-
- // To avoid the problem we should perform access to VaadinSession of recipient behalf of
- // recipient.
String sessionUsername = usernameSessionEntry.getKey();
List sessions = usernameSessionEntry.getValue();
for (VaadinSession session : sessions) {
- systemAuthenticator.runWithUser(sessionUsername,
- // obtain lock on session state
- () -> session.access(() -> onSessionAccess(session, event)));
+ session.access(() -> {
+ if (session.getState() != VaadinSessionState.OPEN) {
+ return;
+ }
+ // Resolve and install the recipient's context when the queued callback actually runs.
+ SecurityContext recipientContext = getSessionSecurityContext(session);
+ if (recipientContext != null) {
+ SecurityContext previousContext = SecurityContextHolder.getContext();
+ SecurityContextHelper.installContext(recipientContext);
+ try {
+ onSessionAccess(session, event);
+ } finally {
+ SecurityContextHelper.restoreContext(previousContext);
+ }
+ } else {
+ systemAuthenticator.runWithUser(sessionUsername, () -> onSessionAccess(session, event));
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Returns the security context stored in the HTTP session of the given Vaadin session, or null if there is none.
+ */
+ @Nullable
+ protected SecurityContext getSessionSecurityContext(VaadinSession session) {
+ WrappedSession wrappedSession = session.getSession();
+ if (wrappedSession == null) {
+ return null;
+ }
+ try {
+ Object attribute = wrappedSession.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
+ if (attribute instanceof SecurityContext securityContext && securityContext.getAuthentication() != null) {
+ return securityContext;
}
+ } catch (IllegalStateException e) {
+ // the HTTP session is invalidated
+ log.debug("Cannot read security context of an invalidated session", e);
}
+ return null;
}
protected void onSessionAccess(VaadinSession session, ApplicationEvent event) {
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java
index 0b8b9b31d8..2d790494d5 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecurityRunnable.java
@@ -18,6 +18,7 @@
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
/**
* Wraps a delegate {@link Runnable} with logic for setting up an {@link SecurityContext} before invoking the delegate
@@ -32,7 +33,7 @@ public class DelegatingSecurityRunnable implements Runnable {
private final SecurityContext securityContext;
public DelegatingSecurityRunnable(Runnable delegate) {
- this(delegate, SecurityContextHolder.getContext());
+ this(delegate, copyOfCurrentContext());
}
public DelegatingSecurityRunnable(Runnable delegate, SecurityContext securityContext) {
@@ -47,7 +48,19 @@ public void run() {
SecurityContextHolder.setContext(securityContext);
delegate.run();
} finally {
- SecurityContextHolder.setContext(originalSecurityContext);
+ if (SecurityContextHolder.createEmptyContext().equals(originalSecurityContext)) {
+ SecurityContextHolder.clearContext();
+ } else {
+ SecurityContextHolder.setContext(originalSecurityContext);
+ }
}
}
+
+ /**
+ * The current context instance may be shared with the HTTP session and other threads, so the delegate gets a
+ * copy holding the same {@link org.springframework.security.core.Authentication}.
+ */
+ private static SecurityContext copyOfCurrentContext() {
+ return new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication());
+ }
}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java
index 69895c5ff3..4502a24872 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/asynctask/DelegatingSecuritySupplier.java
@@ -18,6 +18,7 @@
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
import java.util.function.Supplier;
@@ -34,7 +35,7 @@ public class DelegatingSecuritySupplier implements Supplier {
private final SecurityContext securityContext;
public DelegatingSecuritySupplier(Supplier delegate) {
- this(delegate, SecurityContextHolder.getContext());
+ this(delegate, copyOfCurrentContext());
}
public DelegatingSecuritySupplier(Supplier delegate, SecurityContext securityContext) {
@@ -49,7 +50,19 @@ public T get() {
SecurityContextHolder.setContext(securityContext);
return delegate.get();
} finally {
- SecurityContextHolder.setContext(originalSecurityContext);
+ if (SecurityContextHolder.createEmptyContext().equals(originalSecurityContext)) {
+ SecurityContextHolder.clearContext();
+ } else {
+ SecurityContextHolder.setContext(originalSecurityContext);
+ }
}
}
+
+ /**
+ * The current context instance may be shared with the HTTP session and other threads, so the delegate gets a
+ * copy holding the same {@link org.springframework.security.core.Authentication}.
+ */
+ private static SecurityContext copyOfCurrentContext() {
+ return new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication());
+ }
}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java
index b457e8324a..0511d713a4 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/backgroundtask/impl/BackgroundWorkerImpl.java
@@ -28,6 +28,7 @@
import io.jmix.core.impl.metadata.MetadataGenerationScope;
import io.jmix.core.impl.session.ThreadLocalSessionData;
import io.jmix.core.security.CurrentAuthentication;
+import io.jmix.core.impl.logging.LogMdc;
import io.jmix.core.security.SecurityContextHelper;
import io.jmix.flowui.backgroundtask.*;
import io.jmix.flowui.event.BackgroundTaskUnhandledExceptionEvent;
@@ -40,6 +41,8 @@
import org.springframework.context.ApplicationEventPublisher;
import org.jspecify.annotations.Nullable;
import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@@ -200,9 +203,10 @@ private TaskExecutorImpl(UI ui, BackgroundTaskManager taskManager, BackgroundTas
this.future = new FutureTask<>(this) {
@Override
protected void done() {
- Authentication previousAuth = SecurityContextHelper.getAuthentication();
+ // may run on the task thread or, on cancel, on a UI thread
+ SecurityContext previousContext = SecurityContextHolder.getContext();
- SecurityContextHelper.setAuthentication(authentication);
+ SecurityContextHelper.installContext(createSecurityContext());
ThreadLocalSessionData.setAttributes(sessionAttributes);
ThreadLocalVaadinRequestHolder.setRequest(vaadinRequest);
try {
@@ -216,7 +220,7 @@ protected void done() {
"to canceling task after session is invalidated");
cancelExecution();
} finally {
- SecurityContextHelper.setAuthentication(previousAuth);
+ SecurityContextHelper.restoreContext(previousContext);
ThreadLocalSessionData.clear();
ThreadLocalVaadinRequestHolder.clear();
}
@@ -224,6 +228,15 @@ protected void done() {
};
}
+ /**
+ * Creates a new context holding the authentication the task was started with.
+ */
+ private SecurityContext createSecurityContext() {
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(authentication);
+ return context;
+ }
+
@Override
public final V call() throws Exception {
String threadName = Thread.currentThread().getName();
@@ -232,7 +245,10 @@ public final V call() throws Exception {
Thread.currentThread().setName(THREAD_NAME_PREFIX + matcher.group(1) + "-" + username);
}
- SecurityContextHelper.setAuthentication(authentication);
+ // The task thread gets its own context instance; the one captured on the UI thread may be shared
+ // with the HTTP session and must not be modified.
+ SecurityContextHolder.setContext(createSecurityContext());
+ LogMdc.setup(authentication);
ThreadLocalSessionData.setAttributes(sessionAttributes);
try {
try (MetadataGenerationScope ignored = metadataGenerationManager.enter(metadataGeneration)) {
@@ -265,7 +281,8 @@ public Map getParams() {
});
}
} finally {
- SecurityContextHelper.setAuthentication(null);
+ SecurityContextHolder.clearContext();
+ LogMdc.setup(null);
ThreadLocalSessionData.clear();
}
}
@@ -274,8 +291,14 @@ public Map getParams() {
@Override
public final void handleProgress(T... changes) {
ui.access(() -> {
+ // Progress handlers always run under the authentication the task was started with, regardless of
+ // the thread that executes the access command and of any SystemAuthenticator block in the task.
+ SecurityContext previousContext = SecurityContextHolder.getContext();
+ SecurityContextHelper.installContext(createSecurityContext());
try (MetadataGenerationScope ignored = metadataGenerationManager.enter(metadataGeneration)) {
process(Arrays.asList(changes));
+ } finally {
+ SecurityContextHelper.restoreContext(previousContext);
}
});
}
@@ -310,12 +333,12 @@ protected final void handleDone() {
unregister();
// As "handleDone()" can be processed under BackgroundTask thread or under UI thread from which
- // the task starts, we should save previous security context (that can be null)
- // to restore it when "done()" is finished.
- Authentication previousAuth = SecurityContextHelper.getAuthentication();
+ // the task starts, we should save the previous security context to restore it when "done()" is finished.
+ // The previous context instance may be shared with the HTTP session and must not be modified.
+ SecurityContext previousContext = SecurityContextHolder.getContext();
try {
- SecurityContextHelper.setAuthentication(authentication);
+ SecurityContextHelper.installContext(createSecurityContext());
V result = future.get();
@@ -344,7 +367,7 @@ protected final void handleDone() {
}
}
} finally {
- SecurityContextHelper.setAuthentication(previousAuth);
+ SecurityContextHelper.restoreContext(previousContext);
if (finalizer != null) {
finalizer.run();
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java
new file mode 100644
index 0000000000..79e63db946
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/JmixSecurityContextHolderStrategy.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 io.jmix.flowui.sys;
+
+import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategy;
+import io.jmix.core.common.util.Preconditions;
+import io.jmix.core.security.SystemAuthenticator;
+import io.jmix.core.security.ThreadSecurityContextOverride;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.function.Supplier;
+
+/**
+ * {@link SecurityContextHolderStrategy} for FlowUI applications.
+ *
+ * Delegates to Vaadin's {@link VaadinAwareSecurityContextHolderStrategy}, which prefers the security context stored
+ * in the current Vaadin session over the thread-local one. On top of that, it lets {@link SystemAuthenticator}
+ * install a context that takes precedence for the current thread only, so that {@code begin()}/{@code end()} take
+ * effect on UI threads and never modify the context shared by all threads of the HTTP session.
+ *
+ * {@link #setContext(SecurityContext)} replaces the context within the current override, preserving the enclosing
+ * scopes. {@link #clearContext()} drops all overrides when a request or task releases its thread.
+ */
+@NullMarked
+public class JmixSecurityContextHolderStrategy implements SecurityContextHolderStrategy, ThreadSecurityContextOverride {
+
+ private final SecurityContextHolderStrategy delegate;
+
+ private final ThreadLocal<@Nullable Deque> overrides = new ThreadLocal<>();
+
+ public JmixSecurityContextHolderStrategy() {
+ this(new VaadinAwareSecurityContextHolderStrategy());
+ }
+
+ public JmixSecurityContextHolderStrategy(SecurityContextHolderStrategy delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public SecurityContext getContext() {
+ Deque stack = overrides.get();
+ if (stack != null && !stack.isEmpty()) {
+ return stack.getFirst();
+ }
+ return delegate.getContext();
+ }
+
+ @Override
+ public Supplier getDeferredContext() {
+ Deque stack = overrides.get();
+ if (stack != null && !stack.isEmpty()) {
+ SecurityContext context = stack.getFirst();
+ return () -> context;
+ }
+ return delegate.getDeferredContext();
+ }
+
+ @Override
+ public void setContext(SecurityContext context) {
+ Preconditions.checkNotNullArgument(context);
+ Deque stack = overrides.get();
+ if (stack != null && !stack.isEmpty()) {
+ stack.pop();
+ stack.push(context);
+ } else {
+ delegate.setContext(context);
+ }
+ }
+
+ @Override
+ public void setDeferredContext(Supplier deferredContext) {
+ Preconditions.checkNotNullArgument(deferredContext);
+ Deque stack = overrides.get();
+ if (stack != null && !stack.isEmpty()) {
+ setContext(deferredContext.get());
+ } else {
+ delegate.setDeferredContext(deferredContext);
+ }
+ }
+
+ @Override
+ public void clearContext() {
+ overrides.remove();
+ delegate.clearContext();
+ }
+
+ @Override
+ public SecurityContext createEmptyContext() {
+ return delegate.createEmptyContext();
+ }
+
+ @Override
+ public void pushContext(SecurityContext context) {
+ Deque stack = overrides.get();
+ if (stack == null) {
+ stack = new ArrayDeque<>();
+ overrides.set(stack);
+ }
+ stack.push(context);
+ }
+
+ @Override
+ public void popContext() {
+ Deque stack = overrides.get();
+ if (stack != null) {
+ stack.poll();
+ if (stack.isEmpty()) {
+ overrides.remove();
+ }
+ }
+ }
+}
diff --git a/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy b/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy
new file mode 100644
index 0000000000..bd559c64a4
--- /dev/null
+++ b/jmix-flowui/flowui/src/test/groovy/asynctask/DelegatingSecurityWrappersTest.groovy
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 asynctask
+
+import io.jmix.flowui.asynctask.DelegatingSecurityRunnable
+import io.jmix.flowui.asynctask.DelegatingSecuritySupplier
+import org.springframework.security.authentication.TestingAuthenticationToken
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContext
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.security.core.context.SecurityContextImpl
+import spock.lang.Specification
+
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+class DelegatingSecurityWrappersTest extends Specification {
+
+ Authentication userAuth = new TestingAuthenticationToken('user', 'pw')
+ Authentication otherAuth = new TestingAuthenticationToken('other', 'pw')
+ SecurityContext callerContext = new SecurityContextImpl(userAuth)
+ ExecutorService worker = Executors.newSingleThreadExecutor()
+
+ def setup() {
+ SecurityContextHolder.setContext(callerContext)
+ }
+
+ def cleanup() {
+ worker.shutdownNow()
+ SecurityContextHolder.clearContext()
+ }
+
+ def "runnable runs with the caller's authentication but changes to the context stay on the worker thread"() {
+ given:
+ Authentication seen = null
+ def runnable = new DelegatingSecurityRunnable({
+ seen = SecurityContextHolder.getContext().getAuthentication()
+ SecurityContextHolder.getContext().setAuthentication(otherAuth)
+ })
+
+ when:
+ worker.submit(runnable).get(5, TimeUnit.SECONDS)
+
+ then:
+ seen.is(userAuth)
+ callerContext.getAuthentication().is(userAuth)
+ }
+
+ def "supplier runs with the caller's authentication but changes to the context stay on the worker thread"() {
+ given:
+ def supplier = new DelegatingSecuritySupplier({
+ Authentication seen = SecurityContextHolder.getContext().getAuthentication()
+ SecurityContextHolder.getContext().setAuthentication(otherAuth)
+ return seen
+ })
+
+ when:
+ Authentication seen = worker.submit({ supplier.get() } as java.util.concurrent.Callable).get(5, TimeUnit.SECONDS)
+
+ then:
+ seen.is(userAuth)
+ callerContext.getAuthentication().is(userAuth)
+ }
+}
diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy
new file mode 100644
index 0000000000..799b24c7b2
--- /dev/null
+++ b/jmix-flowui/flowui/src/test/groovy/security_context/BackgroundTaskSecurityContextTest.groovy
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 security_context
+
+import com.vaadin.flow.component.UI
+import com.vaadin.flow.server.VaadinSession
+import io.jmix.core.security.SystemAuthenticationToken
+import io.jmix.flowui.backgroundtask.BackgroundTask
+import io.jmix.flowui.backgroundtask.BackgroundWorker
+import io.jmix.flowui.backgroundtask.TaskLifeCycle
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContextHolder
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Background tasks are started from a UI thread of the 'admin' session. The task runs on a worker thread,
+ * while progress and done handlers run through {@code ui.access()} inside the session.
+ */
+@SpringBootTest
+class BackgroundTaskSecurityContextTest extends SessionSecurityContextSpecification {
+
+ @Autowired
+ BackgroundWorker backgroundWorker
+
+ VaadinSession taskSession
+ UI taskUi
+
+ void setup() {
+ taskSession = createSessionWithImmediateAccess(sessionContext)
+ taskUi = createUi(taskSession, 3)
+ VaadinSession.setCurrent(taskSession)
+ UI.setCurrent(taskUi)
+ }
+
+ def "task body runs under the user's authentication and withSystem inside the task does not affect the session"() {
+ given:
+ def bodyAuth = new AtomicReference()
+ def insideSystemAuth = new AtomicReference()
+ def sessionAuthDuringSystem = new AtomicReference()
+ def done = new CountDownLatch(1)
+ def task = new BackgroundTask(10) {
+ @Override
+ Void run(TaskLifeCycle lifeCycle) {
+ bodyAuth.set(SecurityContextHolder.getContext().getAuthentication())
+ systemAuthenticator.runWithSystem {
+ insideSystemAuth.set(SecurityContextHolder.getContext().getAuthentication())
+ sessionAuthDuringSystem.set(sessionContext.getAuthentication())
+ }
+ return null
+ }
+
+ @Override
+ void done(Void result) {
+ done.countDown()
+ }
+ }
+
+ when:
+ backgroundWorker.handle(task).execute()
+
+ then:
+ done.await(10, TimeUnit.SECONDS)
+ bodyAuth.get().is(adminAuth)
+ insideSystemAuth.get() instanceof SystemAuthenticationToken
+ sessionAuthDuringSystem.get().is(adminAuth)
+ }
+
+ def "progress handler runs under the user's authentication even when published from inside withSystem"() {
+ given:
+ def progressAuth = new AtomicReference()
+ def done = new CountDownLatch(1)
+ def task = new BackgroundTask(10) {
+ @Override
+ Void run(TaskLifeCycle lifeCycle) {
+ systemAuthenticator.runWithSystem {
+ lifeCycle.publish(1)
+ }
+ return null
+ }
+
+ @Override
+ void progress(List changes) {
+ progressAuth.set(SecurityContextHolder.getContext().getAuthentication())
+ }
+
+ @Override
+ void done(Void result) {
+ done.countDown()
+ }
+ }
+
+ when:
+ backgroundWorker.handle(task).execute()
+
+ then:
+ done.await(10, TimeUnit.SECONDS)
+ progressAuth.get().is(adminAuth)
+ }
+
+ def "done handler does not modify the session security context when the task was started under withSystem"() {
+ given:
+ def doneAuth = new AtomicReference()
+ def sessionAuthDuringDone = new AtomicReference()
+ def done = new CountDownLatch(1)
+ def task = new BackgroundTask(10) {
+ @Override
+ Void run(TaskLifeCycle lifeCycle) {
+ return null
+ }
+
+ @Override
+ void done(Void result) {
+ doneAuth.set(SecurityContextHolder.getContext().getAuthentication())
+ sessionAuthDuringDone.set(sessionContext.getAuthentication())
+ done.countDown()
+ }
+ }
+
+ when: "the task is started from a system block on the UI thread"
+ systemAuthenticator.runWithSystem {
+ backgroundWorker.handle(task).execute()
+ }
+
+ then: "the done handler runs under the authentication the task was started with"
+ done.await(10, TimeUnit.SECONDS)
+ doneAuth.get() instanceof SystemAuthenticationToken
+
+ and: "the session's shared context is never modified"
+ sessionAuthDuringDone.get().is(adminAuth)
+ sessionContext.getAuthentication().is(adminAuth)
+ }
+}
diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy
new file mode 100644
index 0000000000..f5d37dac69
--- /dev/null
+++ b/jmix-flowui/flowui/src/test/groovy/security_context/JmixSecurityContextHolderStrategyTest.groovy
@@ -0,0 +1,341 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 security_context
+
+import com.vaadin.flow.server.VaadinSession
+import io.jmix.core.security.SystemAuthenticationToken
+import io.jmix.flowui.UiEventPublisher
+import io.jmix.flowui.asynctask.DelegatingSecurityRunnable
+import io.jmix.flowui.asynctask.DelegatingSecuritySupplier
+import io.jmix.flowui.sys.event.UiEventsManager
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.context.ApplicationListener
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.Authentication
+import org.springframework.security.concurrent.DelegatingSecurityContextRunnable
+import org.springframework.security.core.context.SecurityContext
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.security.core.context.SecurityContextImpl
+import org.springframework.security.core.userdetails.User
+import ui_events.TestUiEvent
+
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+@SpringBootTest
+class JmixSecurityContextHolderStrategyTest extends SessionSecurityContextSpecification {
+
+ @Autowired
+ UiEventPublisher uiEventPublisher
+
+ def "on a UI thread the session context is current"() {
+ expect:
+ VaadinSession.getCurrent().is(vaadinSession)
+ SecurityContextHolder.getContext().is(sessionContext)
+ }
+
+ def "withSystem takes effect on a UI thread and leaves the session context untouched"() {
+ when:
+ Authentication inside = systemAuthenticator.withSystem {
+ SecurityContextHolder.getContext().getAuthentication()
+ }
+
+ then:
+ inside instanceof SystemAuthenticationToken
+ inside.getName() == 'system'
+ sessionContext.getAuthentication().is(adminAuth)
+ SecurityContextHolder.getContext().is(sessionContext)
+ }
+
+ def "begin on a UI thread is not visible to another thread of the same session"() {
+ given:
+ ExecutorService otherThread = Executors.newSingleThreadExecutor()
+
+ when:
+ systemAuthenticator.begin()
+ Authentication seenByOtherThread = otherThread.submit {
+ VaadinSession.setCurrent(vaadinSession)
+ try {
+ return SecurityContextHolder.getContext().getAuthentication()
+ } finally {
+ VaadinSession.setCurrent(null)
+ }
+ }.get(5, TimeUnit.SECONDS)
+
+ then:
+ seenByOtherThread.is(adminAuth)
+
+ cleanup:
+ systemAuthenticator.end()
+ otherThread.shutdownNow()
+ }
+
+ def "clearContext removes an override left by begin without end"() {
+ when:
+ systemAuthenticator.begin()
+ SecurityContextHolder.clearContext()
+
+ then:
+ SecurityContextHolder.getContext().getAuthentication().is(adminAuth)
+
+ when: "the late end does not throw"
+ systemAuthenticator.end()
+
+ then:
+ SecurityContextHolder.getContext().getAuthentication().is(adminAuth)
+ }
+
+ def "without a Vaadin session the strategy behaves as a thread-local strategy"() {
+ given:
+ VaadinSession.setCurrent(null)
+ SecurityContext threadContext = new SecurityContextImpl(adminAuth)
+ SecurityContextHolder.setContext(threadContext)
+
+ when:
+ Authentication inside = systemAuthenticator.withSystem {
+ SecurityContextHolder.getContext().getAuthentication()
+ }
+
+ then:
+ inside instanceof SystemAuthenticationToken
+ SecurityContextHolder.getContext().is(threadContext)
+ threadContext.getAuthentication().is(adminAuth)
+ }
+
+ def "UI event handlers of a recipient session run under that session's own authentication"() {
+ given:
+ def recipient = recipientSession()
+ def event = new TestUiEvent(this, "eventMessage")
+
+ when: "the event is sent from the admin session"
+ uiEventPublisher.sendEventToUserSessions(event, ['recipient': [recipient.session]])
+
+ then: "the handler ran under the recipient's own session authentication"
+ recipient.handlerAuthentication.is(recipient.auth)
+
+ and: "the sender's context was not modified"
+ sessionContext.getAuthentication().is(adminAuth)
+ SecurityContextHolder.getContext().is(sessionContext)
+
+ cleanup:
+ userRepository.removeUser(recipient.user)
+ }
+
+ def "UI event handlers of a recipient session run under that session's own authentication when sent from withSystem"() {
+ given:
+ def recipient = recipientSession()
+ def event = new TestUiEvent(this, "eventMessage")
+
+ when: "the event is sent from a system block, as a scheduled job would do"
+ systemAuthenticator.runWithSystem {
+ uiEventPublisher.sendEventToUserSessions(event, ['recipient': [recipient.session]])
+ }
+
+ then:
+ recipient.handlerAuthentication.is(recipient.auth)
+ SecurityContextHolder.getContext().is(sessionContext)
+
+ cleanup:
+ userRepository.removeUser(recipient.user)
+ }
+
+ def "test nested #wrapper preserves system authentication and restores the caller (UI session: #withSession)"() {
+ given:
+ if (!withSession) {
+ VaadinSession.setCurrent(null)
+ SecurityContextHolder.setContext(sessionContext)
+ }
+ Authentication inside = null
+ Authentication afterWrapper = null
+
+ when:
+ systemAuthenticator.runWithSystem {
+ Runnable action = { inside = SecurityContextHolder.getContext().authentication }
+ switch (wrapper) {
+ case 'runnable':
+ new DelegatingSecurityRunnable(action).run()
+ break
+ case 'supplier':
+ new DelegatingSecuritySupplier({ action.run() }).get()
+ break
+ case 'Spring runnable':
+ new DelegatingSecurityContextRunnable(action).run()
+ break
+ }
+ afterWrapper = SecurityContextHolder.getContext().authentication
+ }
+
+ then:
+ inside instanceof SystemAuthenticationToken
+ afterWrapper.is(inside)
+ SecurityContextHolder.getContext().is(sessionContext)
+ sessionContext.authentication.is(adminAuth)
+
+ where:
+ [wrapper, withSession] << [['runnable', 'supplier', 'Spring runnable'], [true, false]].combinations()
+ }
+
+ def "test queued UI event uses recipient authentication when processed inside withSystem"() {
+ given:
+ def recipient = recipientSession(false)
+ def worker = Executors.newSingleThreadExecutor()
+ VaadinSession.setCurrent(null)
+ recipient.session.lock()
+
+ when:
+ worker.submit {
+ uiEventPublisher.sendEventToUserSessions(new TestUiEvent(this, 'queued'),
+ ['recipient': [recipient.session]])
+ }.get(5, TimeUnit.SECONDS)
+
+ then:
+ recipient.handlerAuthentication == null
+
+ when:
+ systemAuthenticator.runWithSystem {
+ recipient.session.unlock()
+ assert SecurityContextHolder.getContext().authentication instanceof SystemAuthenticationToken
+ }
+
+ then:
+ recipient.handlerAuthentication.is(recipient.auth)
+
+ cleanup:
+ if (recipient.session.hasLock()) {
+ recipient.session.unlock()
+ }
+ worker.shutdownNow()
+ userRepository.removeUser(recipient.user)
+ }
+
+ def "test explicit wrapper context restores nested authentication after an exception"() {
+ when:
+ systemAuthenticator.runWithSystem {
+ SecurityContext systemContext = SecurityContextHolder.getContext()
+ systemAuthenticator.runWithUser('admin') {
+ SecurityContext userContext = SecurityContextHolder.getContext()
+ try {
+ new DelegatingSecurityRunnable({
+ assert SecurityContextHolder.getContext().is(sessionContext)
+ throw new IllegalStateException('task failed')
+ }, sessionContext).run()
+ assert false: 'The task must throw'
+ } catch (IllegalStateException ignored) {
+ assert SecurityContextHolder.getContext().is(userContext)
+ }
+ }
+ assert SecurityContextHolder.getContext().is(systemContext)
+ }
+
+ then:
+ SecurityContextHolder.getContext().is(sessionContext)
+ sessionContext.authentication.is(adminAuth)
+ }
+
+ def "test deferred context replacement preserves the enclosing authentication scope"() {
+ given:
+ VaadinSession.setCurrent(null)
+ SecurityContextHolder.setContext(sessionContext)
+
+ when:
+ systemAuthenticator.runWithSystem {
+ def previous = SecurityContextHolder.getDeferredContext()
+ try {
+ SecurityContextHolder.setDeferredContext { sessionContext }
+ assert SecurityContextHolder.getContext().is(sessionContext)
+ } finally {
+ SecurityContextHolder.setDeferredContext(previous)
+ }
+ assert SecurityContextHolder.getContext().authentication instanceof SystemAuthenticationToken
+ }
+
+ then:
+ SecurityContextHolder.getContext().is(sessionContext)
+ }
+
+ def "test #wrapper clears unfinished system scopes on a pooled thread"() {
+ given:
+ ExecutorService worker = Executors.newSingleThreadExecutor()
+ Runnable action = {
+ systemAuthenticator.begin()
+ systemAuthenticator.begin()
+ }
+ def task = wrapper == 'runnable' ? new DelegatingSecurityRunnable(action) :
+ new DelegatingSecuritySupplier({ action.run() })
+
+ when:
+ worker.submit {
+ if (task instanceof Runnable) {
+ task.run()
+ } else {
+ task.get()
+ }
+ }.get(5, TimeUnit.SECONDS)
+ Authentication afterLateEnd = worker.submit {
+ systemAuthenticator.end()
+ return SecurityContextHolder.getContext().authentication
+ }.get(5, TimeUnit.SECONDS)
+
+ then:
+ afterLateEnd == null
+ sessionContext.authentication.is(adminAuth)
+
+ cleanup:
+ worker.submit {
+ systemAuthenticator.end()
+ SecurityContextHolder.clearContext()
+ }.get(5, TimeUnit.SECONDS)
+ worker.shutdownNow()
+
+ where:
+ wrapper << ['runnable', 'supplier']
+ }
+
+ /**
+ * A recipient session whose HTTP session holds the recipient's security context, with one UI and an event
+ * listener that records the authentication it ran under.
+ */
+ private RecipientSession recipientSession(boolean immediateAccess = true) {
+ def result = new RecipientSession()
+ result.user = User.builder().username('recipient').password('').authorities(Collections.emptyList()).build()
+ result.auth = new UsernamePasswordAuthenticationToken(result.user, null, result.user.authorities)
+ userRepository.addUser(result.user)
+
+ def context = new SecurityContextImpl(result.auth)
+ result.session = immediateAccess ? createSessionWithImmediateAccess(context) : createSessionWithLock(context)
+ result.session.lock()
+ try {
+ result.session.setAttribute(UiEventsManager, new UiEventsManager())
+ def recipientUi = createUi(result.session, 2)
+ result.session.getAttribute(UiEventsManager).addApplicationListener(recipientUi, { e ->
+ result.handlerAuthentication = SecurityContextHolder.getContext().getAuthentication()
+ } as ApplicationListener)
+ } finally {
+ result.session.unlock()
+ }
+ return result
+ }
+
+ private static class RecipientSession {
+ VaadinSession session
+ User user
+ Authentication auth
+ Authentication handlerAuthentication
+ }
+}
diff --git a/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy b/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy
new file mode 100644
index 0000000000..7e0a592c43
--- /dev/null
+++ b/jmix-flowui/flowui/src/test/groovy/security_context/SessionSecurityContextSpecification.groovy
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2026 Haulmont.
+ *
+ * Licensed 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 security_context
+
+import com.vaadin.flow.component.UI
+import com.vaadin.flow.server.VaadinService
+import com.vaadin.flow.server.VaadinSession
+import com.vaadin.flow.server.WrappedHttpSession
+import io.jmix.core.security.InMemoryUserRepository
+import io.jmix.flowui.backgroundtask.BackgroundTaskManager
+import io.jmix.flowui.sys.JmixSecurityContextHolderStrategy
+import io.jmix.flowui.sys.event.UiEventsManager
+import io.jmix.flowui.testassist.vaadin.TestUI
+import io.jmix.flowui.testassist.vaadin.TestVaadinRequest
+import io.jmix.flowui.testassist.vaadin.TestVaadinSession
+import org.apache.commons.lang3.reflect.FieldUtils
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.mock.web.MockHttpSession
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContext
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.security.core.context.SecurityContextImpl
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository
+import test_support.spec.FlowuiTestSpecification
+
+import java.util.concurrent.locks.Lock
+import java.util.concurrent.locks.ReentrantLock
+
+/**
+ * Runs with {@link JmixSecurityContextHolderStrategy} installed, as in a FlowUI application with Spring Security,
+ * and with a Vaadin session whose HTTP session holds the security context of the logged-in user 'admin'.
+ */
+abstract class SessionSecurityContextSpecification extends FlowuiTestSpecification {
+
+ @Autowired
+ InMemoryUserRepository userRepository
+
+ User admin
+ Authentication adminAuth
+ SecurityContext sessionContext
+
+ @Override
+ protected void setupAuthentication() {
+ SecurityContextHolder.setContextHolderStrategy(new JmixSecurityContextHolderStrategy())
+ }
+
+ @Override
+ protected void removeAuthentication() {
+ SecurityContextHolder.clearContext()
+ SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL)
+ }
+
+ void setup() {
+ admin = User.builder().username('admin').password('').authorities(Collections.emptyList()).build()
+ userRepository.addUser(admin)
+ adminAuth = new UsernamePasswordAuthenticationToken(admin, null, admin.authorities)
+ sessionContext = new SecurityContextImpl(adminAuth)
+ bindHttpSession(vaadinSession, sessionContext)
+ }
+
+ void cleanup() {
+ userRepository.removeUser(admin)
+ VaadinSession.setCurrent(vaadinSession)
+ UI.setCurrent(ui)
+ }
+
+ /**
+ * Gives the Vaadin session an HTTP session that holds the given security context under the key used by
+ * Spring Security, so that the Vaadin-aware strategy finds it.
+ */
+ protected static void bindHttpSession(VaadinSession session, SecurityContext securityContext) {
+ def httpSession = new MockHttpSession()
+ httpSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext)
+ // VaadinSession.refreshTransients() asserts that the session lock is held, which the test session
+ // does not model, so the wrapped session is set directly.
+ FieldUtils.writeField(session, "session", new WrappedHttpSession(httpSession), true)
+ }
+
+ /**
+ * Creates a Vaadin session that runs tasks queued by {@code session.access()} as soon as the lock is released,
+ * as a real session does. {@link TestVaadinSession#unlock()} is a no-op, so such tasks would never run there.
+ */
+ protected VaadinSession createSessionWithImmediateAccess(SecurityContext securityContext) {
+ VaadinService service = vaadinSession.getService()
+ VaadinSession session = new TestVaadinSession(service) {
+ @Override
+ void unlock() {
+ service.runPendingAccessTasks(this)
+ }
+ }
+ bindHttpSession(session, securityContext)
+ session.setAttribute(BackgroundTaskManager, new BackgroundTaskManager())
+ session.setAttribute(UiEventsManager, new UiEventsManager())
+ return session
+ }
+
+ protected UI createUi(VaadinSession session, int uiId) {
+ def newUi = new TestUI()
+ newUi.getInternals().setSession(session)
+ newUi.doInit(new TestVaadinRequest(session.getService()), uiId, "testAppId" + uiId)
+ session.addUI(newUi)
+ return newUi
+ }
+
+ protected VaadinSession createSessionWithLock(SecurityContext securityContext) {
+ VaadinSession session = new VaadinSession(vaadinSession.getService()) {
+ private final ReentrantLock sessionLock = new ReentrantLock()
+
+ @Override
+ Lock getLockInstance() {
+ return sessionLock
+ }
+
+ @Override
+ boolean hasLock() {
+ return sessionLock.isHeldByCurrentThread()
+ }
+ }
+ bindHttpSession(session, securityContext)
+ return session
+ }
+}
diff --git a/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java b/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java
index e2be44d89e..f181bebc99 100644
--- a/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java
+++ b/jmix-reports/reports/src/main/java/io/jmix/reports/libintegration/JmixOfficeIntegration.java
@@ -24,6 +24,7 @@
import com.sun.star.comp.helper.BootstrapException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
import jakarta.annotation.PreDestroy;
import java.util.List;
@@ -41,15 +42,20 @@ public JmixOfficeIntegration(String openOfficePath, List ports) {
@Override
public void runTaskWithTimeout(final OfficeTask officeTask, int timeoutInSeconds) throws NoFreePortsException {
- final SecurityContext securityContext = SecurityContextHolder.getContext();
+ // the current context instance may be shared with the HTTP session, so the task gets a copy
+ final SecurityContext securityContext =
+ new SecurityContextImpl(SecurityContextHolder.getContext().getAuthentication());
final OfficeConnection connection = acquireConnection();
Future future = null;
try {
Callable task = () -> {
SecurityContextHolder.setContext(securityContext);
- connection.open();
- officeTask.processTaskInOpenOffice(connection.getOOResourceProvider());
- SecurityContextHolder.clearContext();
+ try {
+ connection.open();
+ officeTask.processTaskInOpenOffice(connection.getOOResourceProvider());
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
return null;
};
future = executor.submit(task);