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.