diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzConstants.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzConstants.java
new file mode 100644
index 000000000000..6aa457fd376a
--- /dev/null
+++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzConstants.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+/**
+ * Shared constants for UserDetails soft-refresh.
+ *
+ * @author Morten Svanæs
+ */
+public final class AuthzConstants {
+
+ /** HttpSession attribute holding the effective authz generation of the session principal. */
+ public static final String SESSION_AUTHZ_GEN_ATTR = "DHIS2_AUTHZ_GEN";
+
+ private AuthzConstants() {}
+}
diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzService.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzService.java
new file mode 100644
index 000000000000..d6c18010da59
--- /dev/null
+++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzService.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+import org.hisp.dhis.user.UserDetails;
+
+/**
+ * Shared authz freshness service for form/OIDC sessions, JWT, and PAT.
+ *
+ *
Keeps {@link UserDetails} immutable: callers rebuild snapshots when generation stamps change.
+ *
+ * @author Morten Svanæs
+ */
+public interface AuthzService {
+
+ long currentUserGen(@Nonnull String username);
+
+ long currentRoleGen(@Nonnull String roleUid);
+
+ /**
+ * Effective generation for a principal:
+ * {@code max(userGen, max(roleGen for each role id on the principal))}.
+ */
+ long effectiveGen(@Nonnull UserDetails principal);
+
+ long bumpUserAuthz(@Nonnull String username);
+
+ long bumpRoleAuthz(@Nonnull String roleUid);
+
+ void bumpUsers(@Nonnull Collection usernames);
+
+ @CheckForNull
+ UserDetails loadFreshUserDetails(@Nonnull String username);
+
+ /**
+ * Returns {@code current} if still fresh, otherwise a newly built immutable {@link UserDetails}.
+ */
+ @Nonnull
+ UserDetails ensureFresh(@Nonnull UserDetails current);
+}
diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStore.java
new file mode 100644
index 000000000000..f5cf34dbb7f9
--- /dev/null
+++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStore.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import javax.annotation.Nonnull;
+
+/**
+ * Stores dual generation stamps used for soft-refresh of immutable {@code UserDetails}.
+ *
+ * Missing keys are treated as generation {@code 0}. Implementations must be safe for concurrent
+ * bumps.
+ *
+ * @author Morten Svanæs
+ */
+public interface AuthzVersionStore {
+
+ long getUserGen(@Nonnull String username);
+
+ long getRoleGen(@Nonnull String roleUid);
+
+ /** Atomically increments and returns the new user generation. */
+ long bumpUserGen(@Nonnull String username);
+
+ /** Atomically increments and returns the new role generation. */
+ long bumpRoleGen(@Nonnull String roleUid);
+
+ /** Bumps each username once. Order is not significant. */
+ void bumpUserGens(@Nonnull Collection usernames);
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/jwt/Dhis2JwtAuthenticationManagerResolver.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/jwt/Dhis2JwtAuthenticationManagerResolver.java
index bc4b3726343c..39ff97b34822 100644
--- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/jwt/Dhis2JwtAuthenticationManagerResolver.java
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/jwt/Dhis2JwtAuthenticationManagerResolver.java
@@ -44,6 +44,7 @@
import org.hisp.dhis.security.oidc.DhisOidcProviderRepository;
import org.hisp.dhis.user.UserDetails;
import org.hisp.dhis.user.UserService;
+import org.hisp.dhis.user.authz.AuthzService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AuthenticationManager;
@@ -109,6 +110,7 @@ public class Dhis2JwtAuthenticationManagerResolver
@Autowired private DhisOidcProviderRepository clientRegistrationRepository;
@Autowired private Dhis2OAuth2ClientService oAuth2ClientService;
@Autowired private UserService userService;
+ @Autowired private AuthzService authzService;
private final Map authenticationManagers =
new ConcurrentHashMap<>();
@@ -218,7 +220,10 @@ private Converter getTokenConverter(
String mappingValue = jwt.getClaim(mappingClaimKey);
UserDetails currentUserDetails =
switch (mappingClaimKey) {
- case "username" -> userService.createUserDetailsByUsername(mappingValue);
+ case "username" -> {
+ UserDetails loaded = authzService.loadFreshUserDetails(mappingValue);
+ yield loaded != null ? loaded : userService.createUserDetailsByUsername(mappingValue);
+ }
case "email" -> userService.createUserDetailsByOpenId(mappingValue);
default -> throw new InvalidBearerTokenException("Invalid mapping claim");
};
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserGroupService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserGroupService.java
index e4c82736be1d..7168bf29f183 100644
--- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserGroupService.java
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserGroupService.java
@@ -41,6 +41,7 @@
import org.hisp.dhis.common.UID;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.acl.AclService;
+import org.hisp.dhis.user.authz.AuthzService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -53,19 +54,23 @@ public class DefaultUserGroupService implements UserGroupService {
private final AclService aclService;
private final HibernateCacheManager cacheManager;
private final Cache userGroupNameCache;
+ private final AuthzService authzService;
public DefaultUserGroupService(
UserGroupStore userGroupStore,
AclService aclService,
HibernateCacheManager cacheManager,
- CacheProvider cacheProvider) {
+ CacheProvider cacheProvider,
+ AuthzService authzService) {
checkNotNull(userGroupStore);
checkNotNull(aclService);
checkNotNull(cacheManager);
+ checkNotNull(authzService);
this.userGroupStore = userGroupStore;
this.aclService = aclService;
this.cacheManager = cacheManager;
+ this.authzService = authzService;
userGroupNameCache = cacheProvider.createUserGroupNameCache();
}
@@ -98,6 +103,12 @@ public void updateUserGroup(UserGroup userGroup) {
cacheManager.clearQueryCache();
aclService.invalidateCurrentUserGroupInfoCache();
+ // Soft-refresh members when group metadata/membership changes.
+ if (userGroup.getMembers() != null) {
+ userGroup.getMembers().stream()
+ .filter(u -> u != null && u.getUsername() != null)
+ .forEach(u -> authzService.bumpUserAuthz(u.getUsername()));
+ }
}
@Override
@@ -157,6 +168,9 @@ public void addUserToGroups(User user, Collection uids, UserDetails curr
}
}
aclService.invalidateCurrentUserGroupInfoCache();
+ if (user.getUsername() != null) {
+ authzService.bumpUserAuthz(user.getUsername());
+ }
}
@Override
@@ -184,6 +198,9 @@ && canAddOrRemoveMember(userGroup, currentUser)
}
aclService.invalidateCurrentUserGroupInfoCache();
+ if (user.getUsername() != null) {
+ authzService.bumpUserAuthz(user.getUsername());
+ }
}
private Collection getUserGroupsByUid(@Nonnull Collection uids) {
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStoreConfig.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStoreConfig.java
new file mode 100644
index 000000000000..3a002167052c
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/AuthzVersionStoreConfig.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import lombok.extern.slf4j.Slf4j;
+import org.hisp.dhis.condition.RedisDisabledCondition;
+import org.hisp.dhis.condition.RedisEnabledCondition;
+import org.hisp.dhis.external.conf.ConfigurationKey;
+import org.hisp.dhis.external.conf.DhisConfigurationProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Wires {@link AuthzVersionStore}: Redis when Redis is enabled (required for multi-node session
+ * deployments), JDBC otherwise.
+ *
+ * @author Morten Svanæs
+ */
+@Slf4j
+@Configuration
+public class AuthzVersionStoreConfig {
+
+ @Bean
+ @Conditional(RedisEnabledCondition.class)
+ public AuthzVersionStore redisAuthzVersionStore(
+ @Autowired(required = false) @Qualifier("stringRedisTemplate")
+ StringRedisTemplate stringRedisTemplate,
+ DhisConfigurationProvider config) {
+ if (stringRedisTemplate == null) {
+ // Fail closed: Redis sessions without a usable template must not silently go local-only.
+ throw new IllegalStateException(
+ "redis.enabled=true but stringRedisTemplate is unavailable; cannot start AuthzVersionStore");
+ }
+ if (!config.isEnabled(ConfigurationKey.REDIS_ENABLED)) {
+ throw new IllegalStateException("Redis AuthzVersionStore requires redis.enabled=true");
+ }
+ log.info("Using Redis AuthzVersionStore for UserDetails soft-refresh");
+ return new RedisAuthzVersionStore(stringRedisTemplate);
+ }
+
+ @Bean
+ @Conditional(RedisDisabledCondition.class)
+ public AuthzVersionStore jdbcAuthzVersionStore(JdbcTemplate jdbcTemplate) {
+ log.info("Using JDBC AuthzVersionStore for UserDetails soft-refresh (Redis disabled)");
+ return new JdbcAuthzVersionStore(jdbcTemplate);
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/DefaultAuthzService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/DefaultAuthzService.java
new file mode 100644
index 000000000000..3895664f8267
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/DefaultAuthzService.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
+import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.UserService;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+/**
+ * Default {@link AuthzService} with dual-gen math, single-flight rebuild, and short-lived
+ * in-process cache keyed by username+effectiveGen.
+ *
+ * @author Morten Svanæs
+ */
+@Slf4j
+@Service
+public class DefaultAuthzService implements AuthzService {
+
+ private final AuthzVersionStore versionStore;
+ private final UserService userService;
+
+ /** username:effectiveGen -> UserDetails */
+ private final Map detailsCache = new ConcurrentHashMap<>();
+
+ /** username -> in-flight rebuild lock */
+ private final Map rebuildLocks = new ConcurrentHashMap<>();
+
+ private final Map metrics = new ConcurrentHashMap<>();
+
+ public DefaultAuthzService(AuthzVersionStore versionStore, @Lazy UserService userService) {
+ this.versionStore = versionStore;
+ this.userService = userService;
+ }
+
+ @Override
+ public long currentUserGen(@Nonnull String username) {
+ return versionStore.getUserGen(username);
+ }
+
+ @Override
+ public long currentRoleGen(@Nonnull String roleUid) {
+ return versionStore.getRoleGen(roleUid);
+ }
+
+ @Override
+ public long effectiveGen(@Nonnull UserDetails principal) {
+ long max = versionStore.getUserGen(principal.getUsername());
+ for (String roleUid : principal.getUserRoleIds()) {
+ if (roleUid != null) {
+ max = Math.max(max, versionStore.getRoleGen(roleUid));
+ }
+ }
+ return max;
+ }
+
+ @Override
+ public long bumpUserAuthz(@Nonnull String username) {
+ long gen = versionStore.bumpUserGen(username);
+ invalidateUserCache(username);
+ metrics.merge("authz.bump.user", 1L, Long::sum);
+ log.debug("Bumped user authz gen for {} -> {}", username, gen);
+ return gen;
+ }
+
+ @Override
+ public long bumpRoleAuthz(@Nonnull String roleUid) {
+ long gen = versionStore.bumpRoleGen(roleUid);
+ detailsCache.clear();
+ metrics.merge("authz.bump.role", 1L, Long::sum);
+ log.debug("Bumped role authz gen for {} -> {}", roleUid, gen);
+ return gen;
+ }
+
+ @Override
+ public void bumpUsers(@Nonnull Collection usernames) {
+ versionStore.bumpUserGens(usernames);
+ for (String username : usernames) {
+ if (username != null) {
+ invalidateUserCache(username);
+ }
+ }
+ metrics.merge("authz.bump.user", (long) usernames.size(), Long::sum);
+ }
+
+ @Override
+ @CheckForNull
+ public UserDetails loadFreshUserDetails(@Nonnull String username) {
+ Object lock = rebuildLocks.computeIfAbsent(username, k -> new Object());
+ synchronized (lock) {
+ long start = System.nanoTime();
+ UserDetails fresh = userService.createUserDetailsByUsername(username);
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000L;
+ metrics.merge("authz.rebuild.count", 1L, Long::sum);
+ metrics.merge("authz.rebuild.latency.ms", elapsedMs, Long::sum);
+ if (fresh != null) {
+ long trueEffective = effectiveGen(fresh);
+ String cacheKey = username + ":" + trueEffective;
+ UserDetails cached = detailsCache.get(cacheKey);
+ if (cached != null) {
+ return cached;
+ }
+ detailsCache.put(cacheKey, fresh);
+ metrics.merge("authz.soft_refresh", 1L, Long::sum);
+ }
+ return fresh;
+ }
+ }
+
+ @Override
+ @Nonnull
+ public UserDetails ensureFresh(@Nonnull UserDetails current) {
+ // Without a stored gen on UserDetails, compare store gens against zero baseline is wrong.
+ // Callers that already detected staleness should use loadFreshUserDetails.
+ // For PAT/JWT: rebuild when any gen for this principal is > 0 relative to a cached gen key.
+ long effective = effectiveGen(current);
+ String cacheKey = current.getUsername() + ":" + effective;
+ UserDetails cached = detailsCache.get(cacheKey);
+ if (cached != null) {
+ return cached;
+ }
+ UserDetails fresh = loadFreshUserDetails(current.getUsername());
+ return fresh != null ? fresh : current;
+ }
+
+ /** Test/observability helper. */
+ public long metric(@Nonnull String name) {
+ return metrics.getOrDefault(name, 0L);
+ }
+
+ private void invalidateUserCache(String username) {
+ detailsCache.keySet().removeIf(key -> key.startsWith(username + ":"));
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java
new file mode 100644
index 000000000000..2bd1128229a1
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import javax.annotation.Nonnull;
+
+/**
+ * Process-local generation store for tests and non-Redis single-node deployments.
+ *
+ * @author Morten Svanæs
+ */
+public class InMemoryAuthzVersionStore implements AuthzVersionStore {
+
+ private final ConcurrentHashMap userGens = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap roleGens = new ConcurrentHashMap<>();
+
+ @Override
+ public long getUserGen(@Nonnull String username) {
+ AtomicLong gen = userGens.get(username);
+ return gen == null ? 0L : gen.get();
+ }
+
+ @Override
+ public long getRoleGen(@Nonnull String roleUid) {
+ AtomicLong gen = roleGens.get(roleUid);
+ return gen == null ? 0L : gen.get();
+ }
+
+ @Override
+ public long bumpUserGen(@Nonnull String username) {
+ return userGens.computeIfAbsent(username, k -> new AtomicLong(0)).incrementAndGet();
+ }
+
+ @Override
+ public long bumpRoleGen(@Nonnull String roleUid) {
+ return roleGens.computeIfAbsent(roleUid, k -> new AtomicLong(0)).incrementAndGet();
+ }
+
+ @Override
+ public void bumpUserGens(@Nonnull Collection usernames) {
+ for (String username : usernames) {
+ if (username != null && !username.isBlank()) {
+ bumpUserGen(username);
+ }
+ }
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStore.java
new file mode 100644
index 000000000000..3c2948600514
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStore.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import java.util.List;
+import javax.annotation.Nonnull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Durable dual-generation store backed by {@code authz_version}.
+ *
+ * @author Morten Svanæs
+ */
+@RequiredArgsConstructor
+public class JdbcAuthzVersionStore implements AuthzVersionStore {
+
+ private static final String SCOPE_USER = "user";
+ private static final String SCOPE_ROLE = "role";
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public long getUserGen(@Nonnull String username) {
+ return getGen(SCOPE_USER, username);
+ }
+
+ @Override
+ public long getRoleGen(@Nonnull String roleUid) {
+ return getGen(SCOPE_ROLE, roleUid);
+ }
+
+ @Override
+ public long bumpUserGen(@Nonnull String username) {
+ return bumpGen(SCOPE_USER, username);
+ }
+
+ @Override
+ public long bumpRoleGen(@Nonnull String roleUid) {
+ return bumpGen(SCOPE_ROLE, roleUid);
+ }
+
+ @Override
+ public void bumpUserGens(@Nonnull Collection usernames) {
+ for (String username : usernames) {
+ if (username != null && !username.isBlank()) {
+ bumpUserGen(username);
+ }
+ }
+ }
+
+ private long getGen(String scope, String keyName) {
+ List rows =
+ jdbcTemplate.query(
+ "select gen from authz_version where scope = ? and key_name = ?",
+ (rs, rowNum) -> rs.getLong(1),
+ scope,
+ keyName);
+ return rows.isEmpty() ? 0L : rows.get(0);
+ }
+
+ private long bumpGen(String scope, String keyName) {
+ jdbcTemplate.update(
+ """
+ insert into authz_version (scope, key_name, gen, updated_at)
+ values (?, ?, 1, now())
+ on conflict (scope, key_name)
+ do update set gen = authz_version.gen + 1, updated_at = now()
+ """,
+ scope,
+ keyName);
+ return getGen(scope, keyName);
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/RedisAuthzVersionStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/RedisAuthzVersionStore.java
new file mode 100644
index 000000000000..072398e56c24
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/RedisAuthzVersionStore.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.user.authz;
+
+import java.util.Collection;
+import javax.annotation.Nonnull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * Shared dual-generation store for multi-node Redis session deployments.
+ *
+ * @author Morten Svanæs
+ */
+@RequiredArgsConstructor
+public class RedisAuthzVersionStore implements AuthzVersionStore {
+
+ static final String USER_KEY_PREFIX = "dhis2:authz:user:";
+ static final String ROLE_KEY_PREFIX = "dhis2:authz:role:";
+
+ private final StringRedisTemplate redisTemplate;
+
+ @Override
+ public long getUserGen(@Nonnull String username) {
+ return getGen(USER_KEY_PREFIX + username);
+ }
+
+ @Override
+ public long getRoleGen(@Nonnull String roleUid) {
+ return getGen(ROLE_KEY_PREFIX + roleUid);
+ }
+
+ @Override
+ public long bumpUserGen(@Nonnull String username) {
+ Long value = redisTemplate.opsForValue().increment(USER_KEY_PREFIX + username);
+ return value == null ? 0L : value;
+ }
+
+ @Override
+ public long bumpRoleGen(@Nonnull String roleUid) {
+ Long value = redisTemplate.opsForValue().increment(ROLE_KEY_PREFIX + roleUid);
+ return value == null ? 0L : value;
+ }
+
+ @Override
+ public void bumpUserGens(@Nonnull Collection usernames) {
+ for (String username : usernames) {
+ if (username != null && !username.isBlank()) {
+ bumpUserGen(username);
+ }
+ }
+ }
+
+ private long getGen(String key) {
+ String value = redisTemplate.opsForValue().get(key);
+ if (value == null || value.isBlank()) {
+ return 0L;
+ }
+ try {
+ return Long.parseLong(value);
+ } catch (NumberFormatException ex) {
+ return 0L;
+ }
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/DefaultAuthzServiceTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/DefaultAuthzServiceTest.java
new file mode 100644
index 000000000000..61baf8d51497
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/DefaultAuthzServiceTest.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ */
+package org.hisp.dhis.user.authz;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Set;
+import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class DefaultAuthzServiceTest {
+
+ @Mock private UserService userService;
+
+ private InMemoryAuthzVersionStore store;
+ private DefaultAuthzService authzService;
+
+ @BeforeEach
+ void setUp() {
+ store = new InMemoryAuthzVersionStore();
+ authzService = new DefaultAuthzService(store, userService);
+ }
+
+ @Test
+ void effectiveGenIsMaxOfUserAndRoleGens() {
+ UserDetails principal =
+ UserDetails.empty().username("alice").userRoleIds(Set.of("r1", "r2")).build();
+
+ store.bumpUserGen("alice"); // 1
+ store.bumpRoleGen("r1"); // 1
+ store.bumpRoleGen("r2"); // 1
+ store.bumpRoleGen("r2"); // 2
+
+ assertEquals(2L, authzService.effectiveGen(principal));
+ }
+
+ @Test
+ void roleBumpIsO1AndDoesNotTouchUserGen() {
+ assertEquals(1L, authzService.bumpRoleAuthz("r1"));
+ assertEquals(0L, store.getUserGen("alice"));
+ assertEquals(1L, store.getRoleGen("r1"));
+ }
+
+ @Test
+ void ensureFreshRebuildsAfterRoleBump() {
+ UserDetails v1 =
+ UserDetails.empty()
+ .username("alice")
+ .userRoleIds(Set.of("r1"))
+ .allAuthorities(Set.of("F_OLD"))
+ .build();
+ UserDetails v2 =
+ UserDetails.empty()
+ .username("alice")
+ .userRoleIds(Set.of("r1"))
+ .allAuthorities(Set.of("F_NEW"))
+ .build();
+
+ when(userService.createUserDetailsByUsername("alice")).thenReturn(v1, v2);
+
+ UserDetails first = authzService.ensureFresh(v1);
+ assertEquals(Set.of("F_OLD"), first.getAllAuthorities());
+
+ authzService.bumpRoleAuthz("r1");
+ UserDetails second = authzService.ensureFresh(v1);
+ assertEquals(Set.of("F_NEW"), second.getAllAuthorities());
+ assertNotSame(first, second);
+ verify(userService, times(2)).createUserDetailsByUsername("alice");
+ }
+
+ @Test
+ void ensureFreshUsesCacheForSameEffectiveGen() {
+ UserDetails v1 =
+ UserDetails.empty().username("alice").userRoleIds(Set.of("r1")).build();
+ when(userService.createUserDetailsByUsername("alice")).thenReturn(v1);
+
+ UserDetails a = authzService.ensureFresh(v1);
+ UserDetails b = authzService.ensureFresh(v1);
+ assertSame(a, b);
+ verify(userService, times(1)).createUserDetailsByUsername("alice");
+ assertTrue(authzService.metric("authz.soft_refresh") >= 1);
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStoreTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStoreTest.java
new file mode 100644
index 000000000000..c02367919d25
--- /dev/null
+++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStoreTest.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ */
+package org.hisp.dhis.user.authz;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class InMemoryAuthzVersionStoreTest {
+
+ private InMemoryAuthzVersionStore store;
+
+ @BeforeEach
+ void setUp() {
+ store = new InMemoryAuthzVersionStore();
+ }
+
+ @Test
+ void missingKeysAreZero() {
+ assertEquals(0L, store.getUserGen("alice"));
+ assertEquals(0L, store.getRoleGen("roleA"));
+ }
+
+ @Test
+ void bumpUserIncrements() {
+ assertEquals(1L, store.bumpUserGen("alice"));
+ assertEquals(2L, store.bumpUserGen("alice"));
+ assertEquals(2L, store.getUserGen("alice"));
+ assertEquals(0L, store.getUserGen("bob"));
+ }
+
+ @Test
+ void bumpRoleIncrementsIndependently() {
+ assertEquals(1L, store.bumpRoleGen("roleA"));
+ assertEquals(1L, store.bumpRoleGen("roleB"));
+ assertEquals(2L, store.bumpRoleGen("roleA"));
+ assertEquals(2L, store.getRoleGen("roleA"));
+ assertEquals(1L, store.getRoleGen("roleB"));
+ }
+
+ @Test
+ void bumpUserGensBatch() {
+ store.bumpUserGens(List.of("a", "b", "a"));
+ assertEquals(2L, store.getUserGen("a"));
+ assertEquals(1L, store.getUserGen("b"));
+ }
+}
diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java
index 0f90b809dd7d..27f55725c899 100644
--- a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java
+++ b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java
@@ -58,6 +58,7 @@
import org.hisp.dhis.user.User;
import org.hisp.dhis.user.UserRole;
import org.hisp.dhis.user.UserService;
+import org.hisp.dhis.user.authz.AuthzService;
import org.hisp.dhis.user.UserSettingsService;
import org.springframework.stereotype.Component;
@@ -72,6 +73,7 @@ public class UserObjectBundleHook extends AbstractObjectBundleHook {
public static final String PRE_UPDATE_USER_KEY = "preUpdateUser";
private final UserService userService;
+ private final AuthzService authzService;
private final FileResourceService fileResourceService;
@@ -198,12 +200,44 @@ public void preUpdate(User user, User persisted, ObjectBundle bundle) {
}
private Boolean userRolesUpdated(User preUpdateUser, User persistedUser) {
- Set before =
+ Set beforeRoles =
preUpdateUser.getUserRoles().stream().map(UserRole::getUid).collect(Collectors.toSet());
- Set after =
+ Set afterRoles =
persistedUser.getUserRoles().stream().map(UserRole::getUid).collect(Collectors.toSet());
-
- return !Objects.equals(before, after);
+ if (!Objects.equals(beforeRoles, afterRoles)) {
+ return true;
+ }
+ Set beforeOu =
+ preUpdateUser.getOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ Set afterOu =
+ persistedUser.getOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ if (!Objects.equals(beforeOu, afterOu)) {
+ return true;
+ }
+ Set beforeDataView =
+ preUpdateUser.getDataViewOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ Set afterDataView =
+ persistedUser.getDataViewOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ if (!Objects.equals(beforeDataView, afterDataView)) {
+ return true;
+ }
+ Set beforeSearch =
+ preUpdateUser.getTeiSearchOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ Set afterSearch =
+ persistedUser.getTeiSearchOrganisationUnits().stream()
+ .map(ou -> ou.getUid())
+ .collect(Collectors.toSet());
+ return !Objects.equals(beforeSearch, afterSearch);
}
@Override
@@ -220,7 +254,8 @@ public void postUpdate(User persistedUser, ObjectBundle bundle) {
updateUserSettings(persistedUser);
if (Boolean.TRUE.equals(invalidateSessions)) {
- userService.invalidateUserSessions(persistedUser.getUsername());
+ // Soft-refresh: bump user authz gen instead of mass session expire.
+ authzService.bumpUserAuthz(persistedUser.getUsername());
}
bundle.removeExtras(persistedUser, PRE_UPDATE_USER_KEY);
diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java
index ddb6bfb8cff0..6deaf456d6da 100644
--- a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java
+++ b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2004-2022, University of Oslo
+ * Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
@@ -12,7 +12,7 @@
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
- * 3. Neither the name of the copyright holder nor the names of its contributors
+ * 3. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
@@ -34,46 +34,52 @@
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundle;
-import org.hisp.dhis.user.User;
import org.hisp.dhis.user.UserRole;
-import org.hisp.dhis.user.UserService;
+import org.hisp.dhis.user.authz.AuthzService;
import org.springframework.stereotype.Component;
/**
- * @author Morten Svanæs
+ * Soft-refreshes authz when a role's authorities or restrictions change (O(1) role gen bump).
+ * Does not mass-invalidate HTTP sessions.
+ *
+ * @author Morten Svanæs
*/
@Component
@AllArgsConstructor
@Slf4j
public class UserRoleBundleHook extends AbstractObjectBundleHook {
- public static final String INVALIDATE_SESSION_KEY = "shouldInvalidateUserSessions";
+ public static final String BUMP_ROLE_AUTHZ_KEY = "shouldBumpRoleAuthz";
- private final UserService userService;
+ private final AuthzService authzService;
@Override
public void preUpdate(UserRole update, UserRole existing, ObjectBundle bundle) {
- if (update == null) return;
- bundle.putExtras(update, INVALIDATE_SESSION_KEY, userRolesUpdated(update, existing));
+ if (update == null) {
+ return;
+ }
+ bundle.putExtras(update, BUMP_ROLE_AUTHZ_KEY, roleAuthzChanged(update, existing));
}
- private Boolean userRolesUpdated(UserRole update, UserRole existing) {
+ private Boolean roleAuthzChanged(UserRole update, UserRole existing) {
Set newAuthorities = update.getAuthorities();
Set existingAuthorities = existing.getAuthorities();
- return !Objects.equals(newAuthorities, existingAuthorities);
+ Set newRestrictions = update.getRestrictions();
+ Set existingRestrictions = existing.getRestrictions();
+ return !Objects.equals(newAuthorities, existingAuthorities)
+ || !Objects.equals(newRestrictions, existingRestrictions);
}
@Override
public void postUpdate(UserRole updatedUserRole, ObjectBundle bundle) {
- final Boolean invalidateSessions =
- (Boolean) bundle.getExtras(updatedUserRole, INVALIDATE_SESSION_KEY);
+ final Boolean bump =
+ (Boolean) bundle.getExtras(updatedUserRole, BUMP_ROLE_AUTHZ_KEY);
- if (Boolean.TRUE.equals(invalidateSessions)) {
- for (User user : updatedUserRole.getUsers()) {
- userService.invalidateUserSessions(user.getUsername());
- }
+ if (Boolean.TRUE.equals(bump) && updatedUserRole.getUid() != null) {
+ authzService.bumpRoleAuthz(updatedUserRole.getUid());
+ log.debug("Bumped role authz gen for role {}", updatedUserRole.getUid());
}
- bundle.removeExtras(updatedUserRole, INVALIDATE_SESSION_KEY);
+ bundle.removeExtras(updatedUserRole, BUMP_ROLE_AUTHZ_KEY);
}
}
diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/test/java/org/hisp/dhis/dxf2/metadata/objectbundle/ObjectBundleHooksTest.java b/dhis-2/dhis-services/dhis-service-dxf2/src/test/java/org/hisp/dhis/dxf2/metadata/objectbundle/ObjectBundleHooksTest.java
index 8bb277bc2a23..0199d15715f8 100644
--- a/dhis-2/dhis-services/dhis-service-dxf2/src/test/java/org/hisp/dhis/dxf2/metadata/objectbundle/ObjectBundleHooksTest.java
+++ b/dhis-2/dhis-services/dhis-service-dxf2/src/test/java/org/hisp/dhis/dxf2/metadata/objectbundle/ObjectBundleHooksTest.java
@@ -63,7 +63,7 @@ class ObjectBundleHooksTest {
new ObjectBundleHooks(
asList(
new OrganisationUnitObjectBundleHook(null, null, null),
- new UserObjectBundleHook(null, null, null, null, null),
+ new UserObjectBundleHook(null, null, null, null, null, null),
new IdentifiableObjectBundleHook(null),
new VersionedObjectObjectBundleHook(),
new AnalyticalObjectObjectBundleHook(null)));
diff --git a/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.44/V2_44_16__authz_version.sql b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.44/V2_44_16__authz_version.sql
new file mode 100644
index 000000000000..5b38f8155950
--- /dev/null
+++ b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.44/V2_44_16__authz_version.sql
@@ -0,0 +1,8 @@
+-- Dual generation stamps for UserDetails soft-refresh (role/user authz versioning).
+CREATE TABLE IF NOT EXISTS authz_version (
+ scope VARCHAR(16) NOT NULL,
+ key_name VARCHAR(255) NOT NULL,
+ gen BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (scope, key_name)
+);
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java
index babcddff6ac6..fc858b1edcb4 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java
@@ -46,6 +46,8 @@
import org.hisp.dhis.setting.SystemSettingsProvider;
import org.hisp.dhis.user.User;
import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.authz.AuthzConstants;
+import org.hisp.dhis.user.authz.AuthzService;
import org.hisp.dhis.user.UserService;
import org.hisp.dhis.webapi.controller.security.LoginResponse.STATUS;
import org.springframework.beans.factory.annotation.Autowired;
@@ -109,6 +111,8 @@ public class AuthenticationController {
@Autowired private SessionRegistry sessionRegistry;
@Autowired private UserService userService;
+ @Autowired private AuthzService authzService;
+
@Autowired protected ApplicationEventPublisher eventPublisher;
@Autowired private HttpSessionEventPublisher httpSessionEventPublisher;
@@ -229,6 +233,10 @@ private void saveContext(
HttpSession session = request.getSession(true);
httpSessionEventPublisher.sessionCreated(new HttpSessionEvent(session));
+ if (authentication.getPrincipal() instanceof UserDetails userDetails) {
+ session.setAttribute(
+ AuthzConstants.SESSION_AUTHZ_GEN_ATTR, authzService.effectiveGen(userDetails));
+ }
}
private String getRedirectUrl(
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java
index b39604246e08..f758edbbdd15 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/apikey/ApiTokenAuthManager.java
@@ -29,6 +29,7 @@
*/
package org.hisp.dhis.webapi.security.apikey;
+import java.util.Objects;
import java.util.Optional;
import org.hisp.dhis.cache.Cache;
import org.hisp.dhis.cache.CacheProvider;
@@ -39,6 +40,7 @@
import org.hisp.dhis.user.User;
import org.hisp.dhis.user.UserDetails;
import org.hisp.dhis.user.UserService;
+import org.hisp.dhis.user.authz.AuthzService;
import org.hisp.dhis.util.ObjectUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
@@ -56,13 +58,18 @@
public class ApiTokenAuthManager implements AuthenticationManager {
private final ApiTokenService apiTokenService;
private final UserService userService;
+ private final AuthzService authzService;
private final Cache apiTokenCache;
public ApiTokenAuthManager(
- ApiTokenService apiTokenService, CacheProvider cacheProvider, @Lazy UserService userService) {
+ ApiTokenService apiTokenService,
+ CacheProvider cacheProvider,
+ @Lazy UserService userService,
+ AuthzService authzService) {
this.userService = userService;
this.apiTokenService = apiTokenService;
+ this.authzService = authzService;
this.apiTokenCache = cacheProvider.createApiKeyCache();
}
@@ -78,8 +85,19 @@ public Authentication authenticate(Authentication authentication) throws Authent
final Optional cachedToken = apiTokenCache.getIfPresent(tokenKey);
if (cachedToken.isPresent()) {
- validateTokenExpiry(cachedToken.get().getToken().getExpire());
- return cachedToken.get();
+ ApiTokenAuthenticationToken cached = cachedToken.get();
+ validateTokenExpiry(cached.getToken().getExpire());
+ UserDetails cachedDetails = cached.getPrincipal();
+ if (cachedDetails != null) {
+ UserDetails fresh = authzService.ensureFresh(cachedDetails);
+ if (fresh != null && isAuthzSnapshotStale(cachedDetails, fresh)) {
+ ApiTokenAuthenticationToken refreshed =
+ new ApiTokenAuthenticationToken(cached.getToken(), fresh);
+ apiTokenCache.put(tokenKey, refreshed);
+ return refreshed;
+ }
+ }
+ return cached;
} else {
ApiToken apiToken = apiTokenService.getByKey(tokenKey);
if (apiToken == null) {
@@ -109,7 +127,10 @@ private UserDetails validateAndCreateUserDetails(User createdBy) {
// User lookup and UserDetails creation must happen within a single transaction, so that lazy
// collections (e.g. User.userRoles) are accessible. This code can run on endpoints excluded
// from open-session-in-view, where no Hibernate session is otherwise available.
- UserDetails userDetails = userService.createUserDetailsByUsername(createdBy.getUsername());
+ UserDetails userDetails = authzService.loadFreshUserDetails(createdBy.getUsername());
+ if (userDetails == null) {
+ userDetails = userService.createUserDetailsByUsername(createdBy.getUsername());
+ }
if (userDetails == null) {
throw new ApiTokenAuthenticationException(
ApiTokenErrors.invalidToken("The API token owner does not exists."));
@@ -131,6 +152,16 @@ private UserDetails validateAndCreateUserDetails(User createdBy) {
return userDetails;
}
+
+ private static boolean isAuthzSnapshotStale(UserDetails cached, UserDetails fresh) {
+ return !Objects.equals(cached.getAllAuthorities(), fresh.getAllAuthorities())
+ || !Objects.equals(cached.getUserRoleIds(), fresh.getUserRoleIds())
+ || !Objects.equals(cached.getUserGroupIds(), fresh.getUserGroupIds())
+ || !Objects.equals(cached.getUserOrgUnitIds(), fresh.getUserOrgUnitIds())
+ || !Objects.equals(cached.getUserDataOrgUnitIds(), fresh.getUserDataOrgUnitIds())
+ || !Objects.equals(cached.getUserSearchOrgUnitIds(), fresh.getUserSearchOrgUnitIds());
+ }
+
private static void validateTokenExpiry(Long expiry) {
if (expiry <= System.currentTimeMillis()) {
throw new ApiTokenExpiredException("Failed to authenticate API token, token has expired.");
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilter.java
new file mode 100644
index 000000000000..3948eafe48cd
--- /dev/null
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilter.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.webapi.security.authz;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.IOException;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.hisp.dhis.security.oidc.DhisOidcUser;
+import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.authz.AuthzConstants;
+import org.hisp.dhis.user.authz.AuthzService;
+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.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * Soft-refreshes immutable session {@link UserDetails} when dual-generation stamps change.
+ *
+ * Does not expire sessions. Replaces Authentication principal and explicitly saves the
+ * SecurityContext ({@code requireExplicitSave(true)}). Does not re-register with SessionRegistry.
+ *
+ * @author Morten Svanæs
+ */
+@Slf4j
+public class UserDetailsSoftRefreshFilter extends OncePerRequestFilter {
+
+ private final AuthzService authzService;
+ private final SecurityContextRepository securityContextRepository;
+
+ public UserDetailsSoftRefreshFilter(AuthzService authzService) {
+ this(authzService, new HttpSessionSecurityContextRepository());
+ }
+
+ public UserDetailsSoftRefreshFilter(
+ AuthzService authzService, SecurityContextRepository securityContextRepository) {
+ this.authzService = authzService;
+ this.securityContextRepository = securityContextRepository;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication != null && authentication.isAuthenticated()) {
+ UserDetails current = extractUserDetails(authentication);
+ if (current != null) {
+ long sessionGen = readSessionGen(request);
+ long effective = authzService.effectiveGen(current);
+ if (effective != sessionGen) {
+ UserDetails fresh = authzService.loadFreshUserDetails(current.getUsername());
+ if (fresh != null) {
+ Authentication replacement = rebuildAuthentication(authentication, fresh);
+ if (replacement != null) {
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(replacement);
+ SecurityContextHolder.setContext(context);
+ securityContextRepository.saveContext(context, request, response);
+ writeSessionGen(request, authzService.effectiveGen(fresh));
+ log.debug(
+ "Soft-refreshed UserDetails for {} gen {} -> {}",
+ fresh.getUsername(),
+ sessionGen,
+ effective);
+ }
+ }
+ }
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ private static UserDetails extractUserDetails(Authentication authentication) {
+ Object principal = authentication.getPrincipal();
+ if (principal instanceof DhisOidcUser oidcUser) {
+ Object nested = oidcUser.getUser();
+ if (nested instanceof UserDetails ud) {
+ return ud;
+ }
+ }
+ if (principal instanceof UserDetails ud) {
+ return ud;
+ }
+ return null;
+ }
+
+ private static Authentication rebuildAuthentication(
+ Authentication existing, UserDetails fresh) {
+ Object principal = existing.getPrincipal();
+ if (principal instanceof DhisOidcUser oidcUser
+ && existing instanceof OAuth2AuthenticationToken oauth2) {
+ OidcIdToken idToken = oidcUser.getIdToken();
+ Map attributes = oidcUser.getAttributes();
+ String nameKey = resolveNameAttributeKey(oidcUser);
+ DhisOidcUser newPrincipal = new DhisOidcUser(fresh, attributes, nameKey, idToken);
+ return new OAuth2AuthenticationToken(
+ newPrincipal, fresh.getAuthorities(), oauth2.getAuthorizedClientRegistrationId());
+ }
+ if (principal instanceof UserDetails) {
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken(
+ fresh, existing.getCredentials(), fresh.getAuthorities());
+ token.setDetails(existing.getDetails());
+ return token;
+ }
+ return null;
+ }
+
+ private static String resolveNameAttributeKey(DhisOidcUser oidcUser) {
+ Map attributes = oidcUser.getAttributes();
+ if (attributes.containsKey("sub")) {
+ return "sub";
+ }
+ if (attributes.containsKey("email")) {
+ return "email";
+ }
+ if (!attributes.isEmpty()) {
+ return attributes.keySet().iterator().next();
+ }
+ return "sub";
+ }
+
+ private static long readSessionGen(HttpServletRequest request) {
+ HttpSession session = request.getSession(false);
+ if (session == null) {
+ return 0L;
+ }
+ Object value = session.getAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR);
+ if (value instanceof Long l) {
+ return l;
+ }
+ if (value instanceof Number n) {
+ return n.longValue();
+ }
+ if (value instanceof String s) {
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException ignored) {
+ return 0L;
+ }
+ }
+ return 0L;
+ }
+
+ private static void writeSessionGen(HttpServletRequest request, long gen) {
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.setAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR, gen);
+ }
+ }
+}
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java
index e9a32d5f9354..ba7b3560c33a 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java
@@ -93,6 +93,8 @@
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
+import org.hisp.dhis.user.authz.AuthzService;
+import org.hisp.dhis.webapi.security.authz.UserDetailsSoftRefreshFilter;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
@@ -152,6 +154,8 @@ public static void setApiContextPath(String apiContextPath) {
@Autowired private RequestCache requestCache;
+ @Autowired private AuthzService authzService;
+
private static class CustomRequestMatcher implements RequestMatcher {
private static final Pattern p1 = Pattern.compile("^/api/apps/.+", Pattern.CASE_INSENSITIVE);
private static final Pattern p2 = Pattern.compile("^/apps/.+", Pattern.CASE_INSENSITIVE);
@@ -265,6 +269,7 @@ protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
configureOAuthTokenFilters(http);
http.addFilterAfter(new SessionTimeoutHeaderFilter(), SessionManagementFilter.class);
+ http.addFilterAfter(new UserDetailsSoftRefreshFilter(authzService), SessionTimeoutHeaderFilter.class);
setHttpHeaders(http);
diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilterTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilterTest.java
new file mode 100644
index 000000000000..3b941ad12173
--- /dev/null
+++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/UserDetailsSoftRefreshFilterTest.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2004-2026, University of Oslo
+ * All rights reserved.
+ */
+package org.hisp.dhis.webapi.security.authz;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import java.util.Set;
+import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.authz.AuthzConstants;
+import org.hisp.dhis.user.authz.AuthzService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.context.SecurityContextRepository;
+
+@ExtendWith(MockitoExtension.class)
+class UserDetailsSoftRefreshFilterTest {
+
+ @Mock private AuthzService authzService;
+ @Mock private SecurityContextRepository securityContextRepository;
+ @Mock private FilterChain filterChain;
+
+ private UserDetailsSoftRefreshFilter filter;
+ private MockHttpServletRequest request;
+ private MockHttpServletResponse response;
+ private MockHttpSession session;
+
+ @BeforeEach
+ void setUp() {
+ filter = new UserDetailsSoftRefreshFilter(authzService, securityContextRepository);
+ request = new MockHttpServletRequest();
+ response = new MockHttpServletResponse();
+ session = new MockHttpSession();
+ request.setSession(session);
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void skipsWhenGenMatches() throws Exception {
+ UserDetails principal =
+ UserDetails.empty().username("alice").userRoleIds(Set.of("r1")).build();
+ var auth =
+ new UsernamePasswordAuthenticationToken(principal, "n/a", principal.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ session.setAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR, 3L);
+ when(authzService.effectiveGen(principal)).thenReturn(3L);
+
+ filter.doFilter(request, response, filterChain);
+
+ verify(authzService, never()).loadFreshUserDetails(any());
+ verify(securityContextRepository, never()).saveContext(any(), any(), any());
+ verify(filterChain).doFilter(request, response);
+ assertSame(principal, SecurityContextHolder.getContext().getAuthentication().getPrincipal());
+ }
+
+ @Test
+ void rebuildsAndSavesWhenGenDiffers() throws Exception {
+ UserDetails stale =
+ UserDetails.empty()
+ .username("alice")
+ .userRoleIds(Set.of("r1"))
+ .allAuthorities(Set.of("F_OLD"))
+ .build();
+ UserDetails fresh =
+ UserDetails.empty()
+ .username("alice")
+ .userRoleIds(Set.of("r1"))
+ .allAuthorities(Set.of("F_NEW"))
+ .build();
+ var auth = new UsernamePasswordAuthenticationToken(stale, "n/a", stale.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ session.setAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR, 0L);
+ when(authzService.effectiveGen(stale)).thenReturn(1L);
+ when(authzService.loadFreshUserDetails("alice")).thenReturn(fresh);
+ when(authzService.effectiveGen(fresh)).thenReturn(1L);
+
+ filter.doFilter(request, response, filterChain);
+
+ Object newPrincipal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
+ assertInstanceOf(UserDetails.class, newPrincipal);
+ assertNotSame(stale, newPrincipal);
+ assertEquals(Set.of("F_NEW"), ((UserDetails) newPrincipal).getAllAuthorities());
+ assertEquals(1L, session.getAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR));
+ verify(securityContextRepository).saveContext(any(), eq(request), eq(response));
+ verify(filterChain).doFilter(request, response);
+ }
+
+ @Test
+ void missingSessionGenTreatedAsZero() throws Exception {
+ UserDetails stale =
+ UserDetails.empty().username("alice").userRoleIds(Set.of("r1")).build();
+ UserDetails fresh =
+ UserDetails.empty().username("alice").userRoleIds(Set.of("r1")).build();
+ var auth = new UsernamePasswordAuthenticationToken(stale, "n/a", stale.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ when(authzService.effectiveGen(stale)).thenReturn(2L);
+ when(authzService.loadFreshUserDetails("alice")).thenReturn(fresh);
+ when(authzService.effectiveGen(fresh)).thenReturn(2L);
+
+ filter.doFilter(request, response, filterChain);
+
+ assertEquals(2L, session.getAttribute(AuthzConstants.SESSION_AUTHZ_GEN_ATTR));
+ verify(authzService).loadFreshUserDetails("alice");
+ }
+}
diff --git a/docs/design/2026-07-18-userdetails-soft-refresh.md b/docs/design/2026-07-18-userdetails-soft-refresh.md
new file mode 100644
index 000000000000..b7e2414647b7
--- /dev/null
+++ b/docs/design/2026-07-18-userdetails-soft-refresh.md
@@ -0,0 +1,176 @@
+# Design: Soft-refresh immutable UserDetails (stop mass session kicks)
+
+**Date:** 2026-07-18
+**Author:** Morten Svanæs
+**Status:** Proposal (for review with Stian)
+**Related:**
+- PR https://github.com/dhis2/dhis2-core/pull/24477 (perf batching only; does not change semantics)
+- DHIS2-16539 UserDetails for authority checks
+- DHIS2-17838 inelegant logout when editing own role
+- Consensus plan: `triagebot/.omc/plans/ralplan-userdetails-soft-refresh.md` (Critic APPROVED)
+- Code: `UserRoleBundleHook`, `UserObjectBundleHook`, `DefaultUserService.invalidateUserSessions`, Redis Spring Session, `DhisOidcUser`
+
+## Problem
+
+After the User → `UserDetails` migration, authorization is snapshotted into an effectively immutable `UserDetailsImpl` stored as the Spring Security principal in the HTTP session.
+
+When a `UserRole`'s authorities change, `UserRoleBundleHook.postUpdate` loops every member and calls `userService.invalidateUserSessions(username)`, which expires sessions via `SessionRegistry`.
+
+That was a deliberate compromise to avoid mutating live principals. It is correct for security, but:
+
+1. A role with ~22k users becomes a mass-kick + N×`createUserDetails` storm.
+2. PR #24477 only collapses the N+1 lookups; users still get logged out.
+3. Policy is inconsistent: user groups only soft-invalidate ACL cache (`userGroupInfoCache`) with TODOs to also invalidate sessions; JWT already rebuilds `UserDetails` per request; PAT can keep a cached principal.
+
+## Goal
+
+Keep the **immutable UserDetails value-object model** (no in-place mutation of authorities/groups/OUs on a live instance), but stop treating "authz changed" as "logout everyone".
+
+## Design in one paragraph
+
+Introduce dual generation stamps (`userAuthzGen`, `roleAuthzGen`) and a shared `AuthzService`. Role authority (or restriction) edits bump **one** role generation (O(1)). On the next request, a filter (and JWT/PAT paths) compares session gen to `max(userGen, max(roleGens))`; if stale, **rebuild** a new immutable `UserDetails` and replace the `Authentication` in the SecurityContext/session. Only password change, disable/lock/expiry, and admin force-logout keep hard `SessionRegistry.expireNow`.
+
+## Why this does not abandon immutable UserDetails
+
+Immutability means: treat the DTO as a value object; do not mutate fields after build.
+
+It does **not** mean: the session must keep the same instance forever.
+
+- Login builds snapshot V1.
+- Authz change bumps a gen.
+- Next request builds snapshot V2 and replaces the principal reference.
+- Call sites still see an immutable object.
+
+JWT already works this way today.
+
+## Dual generation (the 22k case)
+
+| Stamp | Bumped when | Write cost |
+|-------|-------------|------------|
+| `userAuthzGen` | user↔role membership, user↔group, user OU sets (orgUnits / dataView / teiSearch), user-level restrictions affecting the snapshot | O(1) per affected user |
+| `roleAuthzGen` | role **authorities** or **restrictions** set changes | O(1) per role |
+
+```text
+effectiveGen = max(userAuthzGen(user), max(roleAuthzGen(r) for r in principal.roleIds))
+```
+
+Changing authorities on a role with 22k members updates **one counter**, not 22k sessions and not 22k user rows.
+
+Membership add/remove bumps only the affected users' `userAuthzGen`. Source of truth is owning side `User.userRoles` (`UserRole.members` is Hibernate `inverse=true` — do not use it alone for deltas).
+
+## Shared AuthzService (v1: all paths, same train)
+
+One service used by:
+
+- form / OIDC session soft-refresh filter
+- JWT bearer converter
+- PAT auth manager
+- login `UserDetailsService` paths
+
+```text
+AuthzService
+ currentUserGen / currentRoleGen / effectiveGen(principal)
+ loadFreshUserDetails(username) // wraps createUserDetails*
+ ensureFresh(UserDetails current) // rebuild if stamp behind
+ bumpUserAuthz / bumpRoleAuthz / bumpUsers
+```
+
+Short-TTL, generation-keyed cache + single-flight amortises concurrent rebuilds after a bump.
+
+## Soft-refresh filter (sessions)
+
+After session SecurityContext is loaded:
+
+1. No DHIS user principal → skip.
+2. Read session attr `DHIS2_AUTHZ_GEN` (missing ⇒ 0).
+3. Compute `effectiveGen`.
+4. If equal → continue.
+5. Else rebuild via `AuthzService`, build a **new** `Authentication` with fresh authorities:
+ - Form: new `UsernamePasswordAuthenticationToken` with fresh principal/authorities.
+ - OIDC: rebuild a new `DhisOidcUser(freshUserDetails, oldAttributes, nameKey, oldIdToken)` — never only swap the nested user field (`DhisOidcUser` keeps authorities in both super and nested user).
+6. Set SecurityContext, then **explicitly** `SecurityContextRepository.saveContext` (`requireExplicitSave(true)` in web security config).
+7. Update `DHIS2_AUTHZ_GEN`. Do **not** re-register with SessionRegistry (principal equals is username-only; hard-kick still works).
+
+No cookie change. No `expireNow`.
+
+## Policy matrix
+
+| Event | Action |
+|-------|--------|
+| Password change | HARD invalidate sessions |
+| Account disable / lock / expiry | HARD |
+| Admin force-logout / DELETE sessions API | HARD |
+| UserRole authorities **or restrictions** change | SOFT `bumpRoleAuthz` |
+| User role membership change (owning side `User.userRoles`) | SOFT `bumpUserAuthz` |
+| UserGroup membership / group update | SOFT bump affected users + existing ACL group cache invalidate |
+| User org-unit assignment change (orgUnits / dataView / teiSearch) | SOFT `bumpUserAuthz` |
+
+## Hook changes (semantic)
+
+**`UserRoleBundleHook`**
+- Today: authorities change → invalidate every member.
+- New: authorities **or restrictions** change → `bumpRoleAuthz(roleUid)`.
+- Member list delta → `bumpUserAuthz` only for added/removed users (owning-side / join-table SoT, not inverse collection alone).
+
+**`UserObjectBundleHook`**
+- Today: role set change → hard invalidate that user.
+- New: role/OU change → `bumpUserAuthz`.
+- Password remains hard via controller paths.
+
+**UserGroup**
+- Keep ACL `invalidateCurrentUserGroupInfoCache`.
+- Add soft bumps for affected members (addresses MAS TODOs without mass logout).
+
+## Storage
+
+- Prefer Redis when enabled (same deployments that already use Spring Session Redis).
+- **Hard rule:** if Redis sessions are enabled, gen store MUST be shared Redis (or shared DB visible to all nodes). Node-local gens are not allowed; misconfiguration fails closed.
+- DB fallback for non-Redis single-node.
+- Document a small best-effort window only for **shared-store** replication lag, not missing shared store.
+
+### Redis keys (v1)
+- `dhis2:authz:user:{username}` / `dhis2:authz:role:{roleUid}` — `INCR` on bump
+- `dhis2:authz:ud:{username}:{effectiveGen}` — short-TTL rebuild cache
+- `dhis2:authz:rebuild:{username}` — single-flight
+
+### DB fallback
+`authz_version(scope, key_name, gen, updated_at)` PK `(scope, key_name)`. Missing row = 0. Do not put gen columns on hot user/role entities in v1.
+
+### Session embedding
+Session attribute `DHIS2_AUTHZ_GEN` (missing ⇒ 0 ⇒ one soft-refresh). Prefer not adding a mandatory new field on `UserDetailsImpl` in the first PR (JDK Redis session serialization).
+
+## Freshness guarantee
+
+**Product decision:** privilege revoke is **next-request best-effort** on a shared gen store. A short shared-store propagation window is acceptable if documented. Not "eventually within minutes", not "only on re-login", and not "node-local is fine".
+
+## Non-goals
+
+- Mutable `UserDetailsImpl` setters for authorities/groups/OUs
+- Eager rewrite of every Redis session document on role change
+- Rebuild UserDetails on every form request unconditionally
+- Replacing PR #24477 (it can still land as a bandage; this design supersedes the semantic)
+
+## Acceptance criteria (high level)
+
+1. Role authority/restriction edit with large membership does not call `expireNow` for members.
+2. Revoked authority is gone for an already-logged-in form session on the next API request (shared-store best-effort window).
+3. Granted authority appears on next request without re-login.
+4. Password / disable / lock / expiry still hard-kick.
+5. Group and OU changes refresh the session principal on next request without logout.
+6. Role authority/restriction edit write path is O(1) (role gen bump).
+7. JWT and PAT cannot stay more privileged than form sessions after a bump (same release train).
+8. `UserDetailsImpl` remains immutable as a value object; OIDC rebuilds full wrapper.
+9. Explicit session save after soft-refresh; no SessionRegistry re-register.
+
+## Suggested implementation order
+
+1. `AuthzVersionStore` + `AuthzService` + single-flight cache + tests
+2. Session attr gen at login; soft-refresh filter + explicit `saveContext` + metrics
+3. Switch role/user/group hooks from hard invalidate to bumps (restrictions + OU + membership SoT)
+4. Route JWT + PAT through `AuthzService` / gen-aware cache (same train)
+5. Keep hard invalidate only on credential/liveness/admin logout paths
+6. Docs + fail-closed Redis-sessions config check
+
+## Bottom line for review
+
+We keep the migration principle (immutable UserDetails, no authz mutation soup). We stop using logout as a poor man's cache invalidation. Mass role edits become O(1) writes + lazy per-active-user rebuild on next request — the same mental model JWT already has.