diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/cache/CacheProvider.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/cache/CacheProvider.java index c533866c441b..392a70a12952 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/cache/CacheProvider.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/cache/CacheProvider.java @@ -96,6 +96,8 @@ public interface CacheProvider { Cache createApiKeyCache(); + Cache createUserDetailsAuthzCache(); + Cache createTeAttributesCache(); Cache createProgramTeAttributesCache(); diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/cache/Region.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/cache/Region.java index 887730faf08c..d3cd56c5ae17 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/cache/Region.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/cache/Region.java @@ -68,5 +68,6 @@ public enum Region { dataIntegrityDetailsCache, queryAliasCache, corsWhitelistCache, - notificationTemplateCache + notificationTemplateCache, + userDetailsAuthzCache } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java index a2faebb1146b..8f4f7962f0da 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java @@ -286,6 +286,23 @@ static UserDetails createUserDetails( @Nonnull Set getUserRoleIds(); + /** + * Soft-refresh stamp: global authz epoch read before this snapshot was built. 0 = unknown; the + * next soft-refresh check verifies the generation and re-stamps or rebuilds. Only {@link + * UserDetailsImpl} carries real stamps. + */ + default long getAuthzCheckedEpoch() { + return 0L; + } + + /** + * Soft-refresh stamp: effective authz generation (max of user and role gens) this snapshot + * reflects. 0 = unknown, which fails safe (extra refresh, never stale). + */ + default long getAuthzGen() { + return 0L; + } + boolean canModifyUser(User userToModify); boolean isExternalAuth(); diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java index 8ccaa01e3118..c2657bf1aa3c 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java @@ -42,11 +42,20 @@ import org.springframework.security.core.GrantedAuthority; @Getter -@Builder +@Builder(toBuilder = true) @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Slf4j public class UserDetailsImpl implements UserDetails { + /** + * Pinned to the pre-stamp shape of this class (release that introduced soft-refresh). Sessions + * serialized by earlier code then deserialize cleanly with {@code authzCheckedEpoch} and {@code + * authzGen} defaulting to 0, which the soft-refresh check treats as "unknown": one refresh on the + * next request instead of a failed deserialization and a forced re-login. Do NOT let this value + * drift; guarded by {@code UserDetailsImplSerializationTest}. + */ + private static final long serialVersionUID = -6804263748578733471L; + private final String uid; @Setter private Long id; private final String code; @@ -78,6 +87,18 @@ public class UserDetailsImpl implements UserDetails { @Nonnull private final Set managedGroupLongIds; @Nonnull private final Set userRoleLongIds; + /** + * Soft-refresh stamp: global authz epoch read BEFORE this snapshot was built. 0 = unknown (e.g. + * login-built or deserialized from a pre-stamp release); the next check verifies the gen. + */ + private final long authzCheckedEpoch; + + /** + * Soft-refresh stamp: effective authz generation (max of user and role gens) this snapshot + * reflects. 0 = unknown, which fails SAFE (one extra refresh, never stale). + */ + private final long authzGen; + @Override public boolean canModifyUser(User other) { if (other == null) { @@ -125,4 +146,9 @@ public boolean isAuthorized(String auth) { public boolean isAuthorized(@Nonnull Authorities auth) { return isAuthorized(auth.toString()); } + + /** Copy with new soft-refresh stamps; all data fields unchanged. */ + public UserDetailsImpl withAuthzStamp(long checkedEpoch, long gen) { + return toBuilder().authzCheckedEpoch(checkedEpoch).authzGen(gen).build(); + } } 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..c5259d942253 --- /dev/null +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/authz/AuthzService.java @@ -0,0 +1,73 @@ +/* + * 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; + +/** + * Soft-refresh facade over authz generation stamps and cached UserDetails snapshots. + * + * @author Morten Svanæs + */ +public interface AuthzService { + + /** + * Epoch-validated, cached, immutable snapshot for username, stamped with the authz epoch read + * before the snapshot was built and the effective generation it reflects (see {@link + * UserDetails#getAuthzCheckedEpoch()}). Returns a snapshot that reflects every authz change + * committed up to the epoch value read at call entry. Null if user unknown. + */ + @CheckForNull + UserDetails getFreshUserDetails(@Nonnull String username); + + /** + * Three-way freshness check against the principal's own authz stamp. Returns: + * + *
    + *
  • the same instance: stamp is current (or the user is unknown) — nothing to do; + *
  • a re-stamped copy: the epoch moved but this principal's generations did not — persist the + * copy so the next check takes the epoch fast path; + *
  • a rebuilt snapshot: this principal's generations moved — persist and use it. + *
+ * + *

Identity comparison with the argument tells the caller whether anything must be persisted. + */ + @Nonnull + UserDetails refreshIfStale(@Nonnull UserDetails principal); + + void bumpUserAuthz(@Nonnull String userUid); + + void bumpRoleAuthz(@Nonnull String roleUid); + + void bumpUsers(@Nonnull Collection userUids); +} 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..fed6ddb9c29f --- /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; + +/** + * Generation stamps that drive UserDetails soft-refresh. + * + *

Every bump advances the global epoch. Bumps participate in the caller's ambient transaction + * (JdbcTemplate joins it), so a reader can never observe a gen/epoch value without also seeing the + * committed data that caused it. + * + * @author Morten Svanæs + */ +public interface AuthzVersionStore { + /** Global epoch; advanced by every bump. Missing row = 0. */ + long getEpoch(); + + /** max(user gen for userUid, role gens for roleUids); missing keys count as 0. */ + long getMaxGen(@Nonnull String userUid, @Nonnull Collection roleUids); + + void bumpUserGen(@Nonnull String userUid); + + void bumpRoleGen(@Nonnull String roleUid); + + /** Bumps each distinct uid once and the epoch once. */ + void bumpUserGens(@Nonnull Collection userUids); +} diff --git a/dhis-2/dhis-api/src/test/java/org/hisp/dhis/user/UserDetailsImplSerializationTest.java b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/user/UserDetailsImplSerializationTest.java new file mode 100644 index 000000000000..ed72d8eab1cd --- /dev/null +++ b/dhis-2/dhis-api/src/test/java/org/hisp/dhis/user/UserDetailsImplSerializationTest.java @@ -0,0 +1,85 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.io.ObjectStreamClass; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Guards the session-compatibility contract of {@link UserDetailsImpl}: the serialVersionUID is + * pinned to the pre-authz-stamp shape of the class, so HTTP sessions serialized by earlier releases + * (Redis / persisted container sessions) deserialize cleanly with stamps defaulting to 0 — one soft + * refresh on the next request instead of a forced re-login. If this test fails you changed the + * serialized shape; do NOT re-pin without deciding the upgrade story. + * + * @author Morten Svanæs + */ +class UserDetailsImplSerializationTest { + + @Test + void serialVersionUidIsPinnedToPreStampShape() { + assertEquals( + -6804263748578733471L, + ObjectStreamClass.lookup(UserDetailsImpl.class).getSerialVersionUID()); + } + + @Test + void withAuthzStampCopiesDataAndReplacesStamps() { + UserDetails original = + UserDetails.empty() + .username("alice") + .uid("u1") + .userRoleIds(Set.of("r1")) + .authzCheckedEpoch(3L) + .authzGen(2L) + .build(); + + UserDetailsImpl stamped = ((UserDetailsImpl) original).withAuthzStamp(7L, 5L); + + assertNotSame(original, stamped); + assertEquals("alice", stamped.getUsername()); + assertEquals("u1", stamped.getUid()); + assertEquals(Set.of("r1"), stamped.getUserRoleIds()); + assertEquals(7L, stamped.getAuthzCheckedEpoch()); + assertEquals(5L, stamped.getAuthzGen()); + } + + @Test + void defaultStampsAreZeroMeaningUnknown() { + UserDetails details = UserDetails.empty().username("bob").uid("u2").build(); + + assertEquals(0L, details.getAuthzCheckedEpoch()); + assertEquals(0L, details.getAuthzGen()); + } +} 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..5dc6dc999b31 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,7 @@ private Converter getTokenConverter( String mappingValue = jwt.getClaim(mappingClaimKey); UserDetails currentUserDetails = switch (mappingClaimKey) { - case "username" -> userService.createUserDetailsByUsername(mappingValue); + case "username" -> authzService.getFreshUserDetails(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..9adb2db0d02a 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(); } @@ -148,14 +153,19 @@ public boolean canAddOrRemoveMember(UserGroup userGroup, @Nonnull UserDetails us @Override @Transactional public void addUserToGroups(User user, Collection uids, UserDetails currentUser) { + boolean modified = false; for (String uid : uids) { UserGroup userGroup = getUserGroup(uid); if (canAddOrRemoveMember(userGroup, currentUser) && userGroupStore.addMember( userGroup.getUID(), user.getUID(), UID.of(currentUser.getUid()))) { user.getGroups().add(userGroup); + modified = true; } } + if (modified) { + authzService.bumpUserAuthz(user.getUid()); + } aclService.invalidateCurrentUserGroupInfoCache(); } @@ -165,6 +175,7 @@ public void updateUserGroups(User user, @Nonnull Collection uids, UserDetai Collection updates = getUserGroupsByUid(uids); UID currentUserUid = UID.of(currentUser.getUid()); UID userUid = user.getUID(); + boolean modified = false; // Remove user from groups they're no longer in (SQL bypass avoids loading UserGroup.members) for (UserGroup userGroup : new HashSet<>(user.getGroups())) { @@ -172,6 +183,7 @@ public void updateUserGroups(User user, @Nonnull Collection uids, UserDetai && canAddOrRemoveMember(userGroup, currentUser) && userGroupStore.removeMember(userGroup.getUID(), userUid, currentUserUid)) { user.getGroups().remove(userGroup); + modified = true; } } @@ -180,9 +192,13 @@ && canAddOrRemoveMember(userGroup, currentUser) if (canAddOrRemoveMember(userGroup, currentUser) && userGroupStore.addMember(userGroup.getUID(), userUid, currentUserUid)) { user.getGroups().add(userGroup); + modified = true; } } + if (modified) { + authzService.bumpUserAuthz(user.getUid()); + } aclService.invalidateCurrentUserGroupInfoCache(); } 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..cdc4cc12a52a --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/DefaultAuthzService.java @@ -0,0 +1,194 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.cache.Cache; +import org.hisp.dhis.cache.CacheProvider; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserDetailsImpl; +import org.hisp.dhis.user.UserService; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +/** + * Epoch-validated, cached UserDetails soft-refresh service. Freshness stamps live on the {@link + * UserDetailsImpl} snapshot itself, so sessions, JWT, and PAT all converge on the same three-way + * check: epoch fast path, gen re-stamp path, rebuild path. + * + *

Stamp-before-build invariant: the stamped {@code authzCheckedEpoch} is always read before + * {@code createUserDetailsByUsername} starts. Hence {@code stamp >= currentEpoch} proves the + * snapshot includes every bump committed up to {@code currentEpoch}. The gen stamp is validated by + * a second epoch read after the build: if the epoch moved during the build, the gen stamp is 0 + * (unknown), which fails SAFE (one extra refresh, never stale). Never reorder these reads. + * + * @author Morten Svanæs + */ +@Slf4j +@Service("org.hisp.dhis.user.authz.AuthzService") +public class DefaultAuthzService implements AuthzService { + + private static final int LOCK_STRIPES = 64; + + private final AuthzVersionStore versionStore; + private final UserService userService; + private final Cache cache; + private final Object[] locks = new Object[LOCK_STRIPES]; + + public DefaultAuthzService( + AuthzVersionStore versionStore, @Lazy UserService userService, CacheProvider cacheProvider) { + this.versionStore = versionStore; + this.userService = userService; + this.cache = cacheProvider.createUserDetailsAuthzCache(); + for (int i = 0; i < LOCK_STRIPES; i++) { + locks[i] = new Object(); + } + } + + @Override + @CheckForNull + public UserDetails getFreshUserDetails(@Nonnull String username) { + long epoch = versionStore.getEpoch(); // MUST be read before any snapshot build + UserDetails entry = cache.getIfPresent(username).orElse(null); + if (isCurrent(entry, epoch)) { + return entry; + } + Object lock = locks[Math.floorMod(username.hashCode(), LOCK_STRIPES)]; + synchronized (lock) { + entry = cache.getIfPresent(username).orElse(null); // double-check under lock + if (isCurrent(entry, epoch)) { + return entry; + } + // Gen fast path: the epoch moved, but this user's gens did not -> re-stamp, skip the + // rebuild. This is what confines rebuild cost to actually affected users. + if (entry instanceof UserDetailsImpl impl) { + long gen = effectiveGen(entry); + if (gen == entry.getAuthzGen()) { + UserDetails restamped = impl.withAuthzStamp(epoch, gen); + cache.put(username, restamped); + return restamped; + } + } + UserDetails fresh = buildStamped(username, epoch); + if (fresh == null) { + cache.invalidate(username); + return null; + } + cache.put(username, fresh); + log.debug("Rebuilt UserDetails snapshot for {} at epoch {}", username, epoch); + return fresh; + } + } + + @Override + @Nonnull + public UserDetails refreshIfStale(@Nonnull UserDetails principal) { + long epoch = versionStore.getEpoch(); + if (epoch == principal.getAuthzCheckedEpoch()) { + return principal; + } + long gen = effectiveGen(principal); // read BEFORE any rebuild, never re-read + if (gen == principal.getAuthzGen() && principal instanceof UserDetailsImpl impl) { + return impl.withAuthzStamp(epoch, gen); + } + UserDetails fresh = getFreshUserDetails(principal.getUsername()); + return fresh != null ? fresh : principal; + } + + private static boolean isCurrent(@CheckForNull UserDetails entry, long epoch) { + return entry != null && entry.getAuthzCheckedEpoch() >= epoch; + } + + /** + * Builds a snapshot and stamps it with the pre-read epoch. The gen stamp is only trusted when a + * second epoch read proves no bump committed during the build; otherwise 0 (unknown) is stamped, + * which forces one gen check on the next epoch move instead of ever serving stale data. + */ + @CheckForNull + private UserDetails buildStamped(@Nonnull String username, long preReadEpoch) { + UserDetails fresh = userService.createUserDetailsByUsername(username); + if (!(fresh instanceof UserDetailsImpl impl)) { + return fresh; // cannot stamp; stays at epoch 0 = always re-checked + } + long gen = effectiveGen(fresh); + long genStamp = versionStore.getEpoch() == preReadEpoch ? gen : 0L; + return impl.withAuthzStamp(preReadEpoch, genStamp); + } + + private long effectiveGen(@Nonnull UserDetails principal) { + String uid = principal.getUid(); + if (uid == null) { + return 0L; + } + return versionStore.getMaxGen(uid, principal.getUserRoleIds()); + } + + @Override + public void bumpUserAuthz(@Nonnull String userUid) { + versionStore.bumpUserGen(userUid); + // No cache invalidation on bumps: the epoch check self-corrects, and in-tx bumps are not + // visible to other readers until commit. + log.debug("Bumped user authz for {}", userUid); + } + + @Override + public void bumpRoleAuthz(@Nonnull String roleUid) { + versionStore.bumpRoleGen(roleUid); + // No cache invalidation on bumps: the epoch check self-corrects, and in-tx bumps are not + // visible to other readers until commit. + log.debug("Bumped role authz for {}", roleUid); + } + + @Override + public void bumpUsers(@Nonnull Collection userUids) { + List filtered = new ArrayList<>(); + for (String uid : userUids) { + if (uid == null) { + continue; + } + String trimmed = uid.trim(); + if (!trimmed.isEmpty()) { + filtered.add(trimmed); + } + } + if (filtered.isEmpty()) { + return; + } + versionStore.bumpUserGens(filtered); + // No cache invalidation on bumps: the epoch check self-corrects, and in-tx bumps are not + // visible to other readers until commit. + log.debug("Bumped authz for {} users", filtered.size()); + } +} 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..e6613af0c224 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStore.java @@ -0,0 +1,164 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import javax.annotation.Nonnull; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSourceUtils; +import org.springframework.stereotype.Repository; + +/** + * JDBC generation-stamp store for UserDetails soft-refresh. + * + *

Every bump advances the global epoch row in the same ambient transaction as the entity change + * that triggered it (JdbcTemplate joins the caller's {@code @Transactional} boundary). Therefore a + * reader can never observe a gen/epoch value without also seeing the committed data that caused it. + * This atomic visibility guarantee is the linchpin of soft-refresh correctness. + * + *

The SQL is PostgreSQL-only, like the production database, and is covered by the Postgres + * integration tests. + * + * @author Morten Svanæs + */ +@Repository("org.hisp.dhis.user.authz.AuthzVersionStore") +public class JdbcAuthzVersionStore implements AuthzVersionStore { + + private static final String SCOPE_USER = "user"; + private static final String SCOPE_ROLE = "role"; + private static final String SCOPE_EPOCH = "epoch"; + private static final String KEY_EPOCH = "epoch"; + + // The existing-row reference must be table-qualified: an unqualified "gen" in the DO UPDATE + // expression is ambiguous against the EXCLUDED pseudo-relation. + private static final String UPSERT_SQL = + """ + insert into authz_version (scope, key_name, gen, updated_at) values (:scope, :key, 1, now()) + on conflict (scope, key_name) do update set gen = authz_version.gen + 1, updated_at = now() + """; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public JdbcAuthzVersionStore(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public long getEpoch() { + try { + Long value = + jdbcTemplate.queryForObject( + "select gen from authz_version where scope = 'epoch' and key_name = 'epoch'", + new MapSqlParameterSource(), + Long.class); + return value == null ? 0L : value; + } catch (EmptyResultDataAccessException ex) { + return 0L; + } + } + + @Override + public long getMaxGen(@Nonnull String userUid, @Nonnull Collection roleUids) { + Long value; + if (roleUids.isEmpty()) { + value = + jdbcTemplate.queryForObject( + """ + select coalesce(max(gen), 0) from authz_version + where scope = 'user' and key_name = :user + """, + new MapSqlParameterSource("user", userUid), + Long.class); + } else { + value = + jdbcTemplate.queryForObject( + """ + select coalesce(max(gen), 0) from authz_version + where (scope = 'user' and key_name = :user) + or (scope = 'role' and key_name in (:roles)) + """, + new MapSqlParameterSource().addValue("user", userUid).addValue("roles", roleUids), + Long.class); + } + return value == null ? 0L : value; + } + + @Override + public void bumpUserGen(@Nonnull String userUid) { + upsert(SCOPE_USER, userUid); + // Epoch last: deadlock-avoidance order (entity key before global epoch). + upsert(SCOPE_EPOCH, KEY_EPOCH); + } + + @Override + public void bumpRoleGen(@Nonnull String roleUid) { + upsert(SCOPE_ROLE, roleUid); + // Epoch last: deadlock-avoidance order (entity key before global epoch). + upsert(SCOPE_EPOCH, KEY_EPOCH); + } + + @Override + public void bumpUserGens(@Nonnull Collection userUids) { + // Distinct, non-blank, sorted for stable lock order. + TreeSet distinct = new TreeSet<>(); + for (String uid : userUids) { + if (uid == null) { + continue; + } + String trimmed = uid.trim(); + if (!trimmed.isEmpty()) { + distinct.add(trimmed); + } + } + if (distinct.isEmpty()) { + return; + } + + List> batchMaps = new ArrayList<>(distinct.size()); + for (String uid : distinct) { + batchMaps.add(Map.of("scope", SCOPE_USER, "key", uid)); + } + jdbcTemplate.batchUpdate(UPSERT_SQL, SqlParameterSourceUtils.createBatch(batchMaps)); + + // One epoch bump for the whole batch. Epoch last for deadlock avoidance. + upsert(SCOPE_EPOCH, KEY_EPOCH); + } + + private void upsert(String scope, String key) { + jdbcTemplate.update( + UPSERT_SQL, new MapSqlParameterSource().addValue("scope", scope).addValue("key", key)); + } +} 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..532587e0b679 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/DefaultAuthzServiceTest.java @@ -0,0 +1,240 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.hisp.dhis.cache.Cache; +import org.hisp.dhis.cache.CacheProvider; +import org.hisp.dhis.cache.SimpleCacheBuilder; +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.mockito.stubbing.Answer; + +/** + * @author Morten Svanæs + */ +class DefaultAuthzServiceTest { + + private InMemoryAuthzVersionStore versionStore; + private UserService userService; + private DefaultAuthzService authzService; + + @BeforeEach + void setUp() { + versionStore = new InMemoryAuthzVersionStore(); + userService = mock(UserService.class); + CacheProvider cacheProvider = mock(CacheProvider.class); + when(cacheProvider.createUserDetailsAuthzCache()) + .thenAnswer( + (Answer>) + invocation -> + new SimpleCacheBuilder<>() + .forRegion("userDetailsAuthzCache-test") + .expireAfterWrite(5, TimeUnit.MINUTES) + .withInitialCapacity(16) + .withMaximumSize(100) + .build()); + authzService = new DefaultAuthzService(versionStore, userService, cacheProvider); + } + + @Test + void sameEpochReturnsSameInstanceAndLoadsOnce() { + when(userService.createUserDetailsByUsername("alice")).thenReturn(details("alice", "u1")); + + UserDetails a = authzService.getFreshUserDetails("alice"); + UserDetails b = authzService.getFreshUserDetails("alice"); + + assertNotNull(a); + assertSame(a, b); + assertEquals(0L, a.getAuthzCheckedEpoch()); + assertEquals(0L, a.getAuthzGen()); + verify(userService, times(1)).createUserDetailsByUsername("alice"); + } + + @Test + void bumpOfOwnRoleForcesRebuild() { + when(userService.createUserDetailsByUsername("alice")) + .thenReturn(details("alice", "u1", Set.of("r1")), details("alice", "u1", Set.of("r1"))); + + UserDetails a = authzService.getFreshUserDetails("alice"); + authzService.bumpRoleAuthz("r1"); + UserDetails b = authzService.getFreshUserDetails("alice"); + + assertNotSame(a, b); + verify(userService, times(2)).createUserDetailsByUsername("alice"); + } + + @Test + void bumpUserAuthzForcesRebuild() { + when(userService.createUserDetailsByUsername("alice")) + .thenReturn(details("alice", "u1"), details("alice", "u1")); + + UserDetails a = authzService.getFreshUserDetails("alice"); + authzService.bumpUserAuthz("u1"); + UserDetails b = authzService.getFreshUserDetails("alice"); + + assertNotSame(a, b); + verify(userService, times(2)).createUserDetailsByUsername("alice"); + } + + @Test + void unrelatedBumpTakesGenFastPathWithoutRebuild() { + when(userService.createUserDetailsByUsername("alice")).thenReturn(details("alice", "u1")); + + UserDetails a = authzService.getFreshUserDetails("alice"); + authzService.bumpUserAuthz("someone-else"); + UserDetails b = authzService.getFreshUserDetails("alice"); + + // Epoch moved but alice's gens did not: re-stamped copy, no rebuild. + assertNotSame(a, b); + assertEquals("alice", b.getUsername()); + assertEquals(1L, b.getAuthzCheckedEpoch()); + assertEquals(0L, b.getAuthzGen()); + verify(userService, times(1)).createUserDetailsByUsername("alice"); + + // And the re-stamped entry serves the epoch fast path afterwards. + assertSame(b, authzService.getFreshUserDetails("alice")); + } + + @Test + void stampBeforeBuildInvariant_entryUsesPreReadEpoch() { + AtomicInteger calls = new AtomicInteger(); + when(userService.createUserDetailsByUsername("alice")) + .thenAnswer( + invocation -> { + int n = calls.incrementAndGet(); + // Simulate a concurrent authz change observed during snapshot build. + versionStore.bumpUserGen("u1"); + return details("alice-" + n, "u1"); + }); + + UserDetails first = authzService.getFreshUserDetails("alice"); + assertNotNull(first); + // The entry is stamped with the pre-build epoch and an "unknown" gen (the epoch moved during + // the build), so the bump during build must force a rebuild on the next check. + assertEquals(0L, first.getAuthzGen()); + UserDetails second = authzService.getFreshUserDetails("alice"); + assertNotNull(second); + assertNotSame(first, second); + verify(userService, times(2)).createUserDetailsByUsername("alice"); + } + + @Test + void unknownUserReturnsNullAndDoesNotCacheNull() { + when(userService.createUserDetailsByUsername("ghost")).thenReturn(null); + + assertNull(authzService.getFreshUserDetails("ghost")); + assertNull(authzService.getFreshUserDetails("ghost")); + verify(userService, times(2)).createUserDetailsByUsername("ghost"); + } + + @Test + void refreshIfStaleWithCurrentStampReturnsSameInstance() { + UserDetails principal = details("alice", "u1"); + + assertSame(principal, authzService.refreshIfStale(principal)); + verify(userService, never()).createUserDetailsByUsername(any()); + } + + @Test + void refreshIfStaleEpochMovedGenUnchangedReturnsRestampedCopy() { + versionStore.bumpUserGen("u1"); // gen 1, epoch 1 + versionStore.bumpRoleGen("r1"); // gen 1, epoch 2 + versionStore.bumpRoleGen("r2"); // gen 1, epoch 3 + versionStore.bumpRoleGen("r2"); // gen 2, epoch 4 + + // Stamp matches the CURRENT effective gen = max(u1, r1, r2) = 2, but an old epoch. + UserDetails principal = + UserDetails.empty() + .username("alice") + .uid("u1") + .userRoleIds(Set.of("r1", "r2")) + .authzCheckedEpoch(3L) + .authzGen(2L) + .build(); + + UserDetails result = authzService.refreshIfStale(principal); + + assertNotSame(principal, result); + assertEquals("alice", result.getUsername()); + assertEquals(4L, result.getAuthzCheckedEpoch()); + assertEquals(2L, result.getAuthzGen()); + verify(userService, never()).createUserDetailsByUsername(any()); + } + + @Test + void refreshIfStaleGenMovedRebuilds() { + UserDetails fresh = details("alice", "u1"); + when(userService.createUserDetailsByUsername("alice")).thenReturn(fresh); + versionStore.bumpUserGen("u1"); // gen 1, epoch 1 + + UserDetails principal = details("alice", "u1"); // stamps (0, 0) + UserDetails result = authzService.refreshIfStale(principal); + + assertNotSame(principal, result); + assertEquals(1L, result.getAuthzCheckedEpoch()); + assertEquals(1L, result.getAuthzGen()); + verify(userService, times(1)).createUserDetailsByUsername("alice"); + } + + @Test + void refreshIfStaleUnknownUserReturnsArgument() { + when(userService.createUserDetailsByUsername("alice")).thenReturn(null); + versionStore.bumpUserGen("u1"); + + UserDetails principal = details("alice", "u1"); + + assertSame(principal, authzService.refreshIfStale(principal)); + } + + private static UserDetails details(String username, String uid) { + return details(username, uid, Set.of()); + } + + private static UserDetails details(String username, String uid, Set roleIds) { + return UserDetails.empty().username(username).uid(uid).userRoleIds(roleIds).build(); + } +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java new file mode 100644 index 000000000000..2f4f3bfa7955 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStore.java @@ -0,0 +1,107 @@ +/* + * 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.Objects; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nonnull; + +/** + * Test-scope in-memory {@link AuthzVersionStore}. Mirrors the JDBC contract: missing keys are 0, + * and each bump (or batch) advances the epoch once. + * + * @author Morten Svanæs + */ +public class InMemoryAuthzVersionStore implements AuthzVersionStore { + + private final ConcurrentHashMap userGens = new ConcurrentHashMap<>(); + private final ConcurrentHashMap roleGens = new ConcurrentHashMap<>(); + private final AtomicLong epoch = new AtomicLong(0); + + @Override + public long getEpoch() { + return epoch.get(); + } + + @Override + public long getMaxGen(@Nonnull String userUid, @Nonnull Collection roleUids) { + long max = genOf(userGens, userUid); + for (String roleUid : roleUids) { + max = Math.max(max, genOf(roleGens, roleUid)); + } + return max; + } + + @Override + public void bumpUserGen(@Nonnull String userUid) { + bump(userGens, userUid); + epoch.incrementAndGet(); + } + + @Override + public void bumpRoleGen(@Nonnull String roleUid) { + bump(roleGens, roleUid); + epoch.incrementAndGet(); + } + + @Override + public void bumpUserGens(@Nonnull Collection userUids) { + TreeSet distinct = new TreeSet<>(); + for (String uid : userUids) { + if (uid == null) { + continue; + } + String trimmed = uid.trim(); + if (!trimmed.isEmpty()) { + distinct.add(trimmed); + } + } + if (distinct.isEmpty()) { + return; + } + for (String uid : distinct) { + bump(userGens, uid); + } + epoch.incrementAndGet(); + } + + private static long genOf(ConcurrentHashMap map, String key) { + AtomicLong value = map.get(key); + return value == null ? 0L : value.get(); + } + + private static void bump(ConcurrentHashMap map, String key) { + Objects.requireNonNull(key); + map.computeIfAbsent(key, k -> new AtomicLong(0)).incrementAndGet(); + } +} 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..4cc8f0b1f214 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/user/authz/InMemoryAuthzVersionStoreTest.java @@ -0,0 +1,100 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * @author Morten Svanæs + */ +class InMemoryAuthzVersionStoreTest { + + private InMemoryAuthzVersionStore store; + + @BeforeEach + void setUp() { + store = new InMemoryAuthzVersionStore(); + } + + @Test + void missingKeysAreZero() { + assertEquals(0L, store.getEpoch()); + assertEquals(0L, store.getMaxGen("u1", Set.of())); + assertEquals(0L, store.getMaxGen("u1", Set.of("r1", "r2"))); + } + + @Test + void userAndRoleBumpsAreIndependent() { + store.bumpUserGen("u1"); + store.bumpRoleGen("r1"); + + assertEquals(1L, store.getMaxGen("u1", Set.of())); + assertEquals(1L, store.getMaxGen("other", Set.of("r1"))); + assertEquals(1L, store.getMaxGen("u1", Set.of("r1"))); + assertEquals(0L, store.getMaxGen("u2", Set.of("r2"))); + } + + @Test + void everyBumpMovesEpoch() { + assertEquals(0L, store.getEpoch()); + store.bumpUserGen("u1"); + assertEquals(1L, store.getEpoch()); + store.bumpRoleGen("r1"); + assertEquals(2L, store.getEpoch()); + store.bumpUserGen("u1"); + assertEquals(3L, store.getEpoch()); + } + + @Test + void batchBumpIsDistinctAndAdvancesEpochOnce() { + store.bumpUserGens(java.util.Arrays.asList("u2", "u1", "u1", "", " ", null, "u2")); + + assertEquals(1L, store.getEpoch()); + assertEquals(1L, store.getMaxGen("u1", Set.of())); + assertEquals(1L, store.getMaxGen("u2", Set.of())); + assertEquals(0L, store.getMaxGen("u3", Set.of())); + } + + @Test + void getMaxGenPicksMaxAcrossUserAndRoles() { + store.bumpUserGen("u1"); // user=1 epoch=1 + store.bumpRoleGen("r1"); // role=1 epoch=2 + store.bumpRoleGen("r2"); // role=1 epoch=3 + store.bumpRoleGen("r2"); // role=2 epoch=4 + + assertEquals(2L, store.getMaxGen("u1", Set.of("r1", "r2"))); + assertEquals(1L, store.getMaxGen("u1", Set.of("r1"))); + assertEquals(2L, store.getMaxGen("unknown", Set.of("r2"))); + } +} diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserGroupObjectBundleHook.java b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserGroupObjectBundleHook.java index c27b8c9f8190..2a2f643dc23e 100644 --- a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserGroupObjectBundleHook.java +++ b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserGroupObjectBundleHook.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 @@ -29,15 +29,71 @@ */ package org.hisp.dhis.dxf2.metadata.objectbundle.hooks; +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundle; import org.hisp.dhis.user.UserGroup; +import org.hisp.dhis.user.authz.AuthzService; import org.springframework.stereotype.Component; +/** + * Metadata import hook for {@link UserGroup}. Group membership affects the sharing/ACL snapshot + * inside {@code UserDetails} ({@code getUserGroupIds}), so membership changes soft-bump affected + * users. Group metadata-only changes (name, etc.) do not touch members. + * + *

{@code UserGroup.members} is the owning side of the membership relation; this is the import + * path that can mutate group membership. + * + * @author Morten Svanæs + */ @Component +@AllArgsConstructor public class UserGroupObjectBundleHook extends AbstractObjectBundleHook { + + public static final String MEMBER_DELTA_KEY = "userGroupMemberDelta"; + + private final AuthzService authzService; + @Override public void preUpdate(UserGroup object, UserGroup persistedObject, ObjectBundle bundle) { handleCreatedUserProperty(object, persistedObject, bundle); + + Set before = uids(persistedObject.getMembers()); + Set after = uids(object.getMembers()); + Set delta = symmetricDifference(before, after); + bundle.putExtras(object, MEMBER_DELTA_KEY, delta); + } + + @Override + @SuppressWarnings("unchecked") + public void postUpdate(UserGroup persistedObject, ObjectBundle bundle) { + Set delta = (Set) bundle.getExtras(persistedObject, MEMBER_DELTA_KEY); + if (delta != null && !delta.isEmpty()) { + authzService.bumpUsers(delta); + } + bundle.removeExtras(persistedObject, MEMBER_DELTA_KEY); + } + + @Override + public void postCreate(UserGroup persistedObject, ObjectBundle bundle) { + bumpAllMembers(persistedObject); + } + + @Override + public void preDelete(UserGroup persistedObject, ObjectBundle bundle) { + bumpAllMembers(persistedObject); + } + + private void bumpAllMembers(UserGroup group) { + Set memberUids = uids(group.getMembers()); + if (!memberUids.isEmpty()) { + authzService.bumpUsers(memberUids); + } } /** @@ -50,4 +106,25 @@ private void handleCreatedUserProperty( userGroup.setCreatedBy(persistedUserGroup.getCreatedBy()); bundle.getPreheat().put(bundle.getPreheatIdentifier(), persistedUserGroup.getCreatedBy()); } + + private static Set uids(Collection objects) { + if (objects == null) { + return Set.of(); + } + return objects.stream() + .filter(Objects::nonNull) + .map(IdentifiableObject::getUid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private static Set symmetricDifference(Set before, Set after) { + Set added = new HashSet<>(after); + added.removeAll(before); + Set removed = new HashSet<>(before); + removed.removeAll(after); + Set delta = new HashSet<>(added); + delta.addAll(removed); + return delta; + } } 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..825b40129f14 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 @@ -29,6 +29,7 @@ */ package org.hisp.dhis.dxf2.metadata.objectbundle.hooks; +import java.util.Collection; import java.util.HashSet; import java.util.Map; import java.util.Objects; @@ -59,6 +60,7 @@ import org.hisp.dhis.user.UserRole; import org.hisp.dhis.user.UserService; import org.hisp.dhis.user.UserSettingsService; +import org.hisp.dhis.user.authz.AuthzService; import org.springframework.stereotype.Component; /** @@ -68,7 +70,7 @@ @AllArgsConstructor public class UserObjectBundleHook extends AbstractObjectBundleHook { public static final String USERNAME = "username"; - public static final String INVALIDATE_SESSIONS_KEY = "shouldInvalidateUserSessions"; + public static final String AUTHZ_SNAPSHOT_CHANGED_KEY = "authzSnapshotChanged"; public static final String PRE_UPDATE_USER_KEY = "preUpdateUser"; private final UserService userService; @@ -81,6 +83,8 @@ public class UserObjectBundleHook extends AbstractObjectBundleHook { private final DhisConfigurationProvider dhisConfig; + private final AuthzService authzService; + @Override public void validate(User user, ObjectBundle bundle, Consumer addReports) { if (bundle.getImportMode().isCreate() && !ValidationUtils.isValidUid(user.getUid())) { @@ -180,7 +184,7 @@ public void preUpdate(User user, User persisted, ObjectBundle bundle) { if (user == null) return; bundle.putExtras(user, PRE_UPDATE_USER_KEY, user); - bundle.putExtras(persisted, INVALIDATE_SESSIONS_KEY, userRolesUpdated(user, persisted)); + bundle.putExtras(persisted, AUTHZ_SNAPSHOT_CHANGED_KEY, authzSnapshotChanged(user, persisted)); if (persisted.getAvatar() != null && (user.getAvatar() == null @@ -197,20 +201,30 @@ public void preUpdate(User user, User persisted, ObjectBundle bundle) { } } - private Boolean userRolesUpdated(User preUpdateUser, User persistedUser) { - Set before = - preUpdateUser.getUserRoles().stream().map(UserRole::getUid).collect(Collectors.toSet()); - Set after = - persistedUser.getUserRoles().stream().map(UserRole::getUid).collect(Collectors.toSet()); + private Boolean authzSnapshotChanged(User preUpdateUser, User persistedUser) { + return !Objects.equals(uids(preUpdateUser.getUserRoles()), uids(persistedUser.getUserRoles())) + || !Objects.equals( + uids(preUpdateUser.getOrganisationUnits()), uids(persistedUser.getOrganisationUnits())) + || !Objects.equals( + uids(preUpdateUser.getDataViewOrganisationUnits()), + uids(persistedUser.getDataViewOrganisationUnits())) + || !Objects.equals( + uids(preUpdateUser.getTeiSearchOrganisationUnits()), + uids(persistedUser.getTeiSearchOrganisationUnits())); + } - return !Objects.equals(before, after); + private static Set uids(Collection objects) { + if (objects == null) { + return Set.of(); + } + return objects.stream().map(IdentifiableObject::getUid).collect(Collectors.toSet()); } @Override public void postUpdate(User persistedUser, ObjectBundle bundle) { final User preUpdateUser = (User) bundle.getExtras(persistedUser, PRE_UPDATE_USER_KEY); - final Boolean invalidateSessions = - (Boolean) bundle.getExtras(persistedUser, INVALIDATE_SESSIONS_KEY); + final Boolean authzChanged = + (Boolean) bundle.getExtras(persistedUser, AUTHZ_SNAPSHOT_CHANGED_KEY); if (!StringUtils.isEmpty(preUpdateUser.getPassword())) { userService.encodeAndSetPassword(persistedUser, preUpdateUser.getPassword()); @@ -219,12 +233,12 @@ public void postUpdate(User persistedUser, ObjectBundle bundle) { updateUserSettings(persistedUser); - if (Boolean.TRUE.equals(invalidateSessions)) { - userService.invalidateUserSessions(persistedUser.getUsername()); + if (Boolean.TRUE.equals(authzChanged)) { + authzService.bumpUserAuthz(persistedUser.getUid()); } bundle.removeExtras(persistedUser, PRE_UPDATE_USER_KEY); - bundle.removeExtras(persistedUser, INVALIDATE_SESSIONS_KEY); + bundle.removeExtras(persistedUser, AUTHZ_SNAPSHOT_CHANGED_KEY); } @Override 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..4adc848b2a82 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 @@ -30,50 +30,62 @@ package org.hisp.dhis.dxf2.metadata.objectbundle.hooks; import java.util.Objects; -import java.util.Set; 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 principals affected by user-role authority or restriction changes by bumping the + * role authz generation (and global epoch) instead of invalidating sessions. + * + *

Role-side membership is intentionally not handled here: {@code UserRole.members} is mapped + * with {@code inverse="true"} and the exposed {@code users} property has no setter, so the metadata + * importer cannot mutate membership from the role side. Membership deltas flow through the owning + * side ({@code User.userRoles}) and are handled by {@link UserObjectBundleHook}. + * + * @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)); + bundle.putExtras(update, BUMP_ROLE_AUTHZ_KEY, roleAuthzChanged(update, existing)); } - private Boolean userRolesUpdated(UserRole update, UserRole existing) { - Set newAuthorities = update.getAuthorities(); - Set existingAuthorities = existing.getAuthorities(); - return !Objects.equals(newAuthorities, existingAuthorities); + private Boolean roleAuthzChanged(UserRole update, UserRole existing) { + return !Objects.equals(update.getAuthorities(), existing.getAuthorities()) + || !Objects.equals(update.getRestrictions(), existing.getRestrictions()); } @Override public void postUpdate(UserRole updatedUserRole, ObjectBundle bundle) { - final Boolean invalidateSessions = - (Boolean) bundle.getExtras(updatedUserRole, INVALIDATE_SESSION_KEY); + final Boolean shouldBump = (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(shouldBump) && updatedUserRole.getUid() != null) { + authzService.bumpRoleAuthz(updatedUserRole.getUid()); } - bundle.removeExtras(updatedUserRole, INVALIDATE_SESSION_KEY); + bundle.removeExtras(updatedUserRole, BUMP_ROLE_AUTHZ_KEY); + } + + @Override + public void preDelete(UserRole persistedObject, ObjectBundle bundle) { + // Bumping the soon-orphaned role key moves the epoch and the gens still referenced by live + // principals' snapshots, forcing their next-request refresh which then drops the deleted role. + // O(1); the orphan authz_version row is harmless. + if (persistedObject.getUid() != null) { + authzService.bumpRoleAuthz(persistedObject.getUid()); + } } } 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_17__authz_version.sql b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.44/V2_44_17__authz_version.sql new file mode 100644 index 000000000000..2dc715342a67 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.44/V2_44_17__authz_version.sql @@ -0,0 +1,9 @@ +-- Generation stamps for UserDetails soft-refresh: per-user ('user', ), per-role +-- ('role', ), and the global change epoch ('epoch', 'epoch'). +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 TIMESTAMP NOT NULL DEFAULT now(), + PRIMARY KEY (scope, key_name) +); diff --git a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/cache/DefaultCacheProvider.java b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/cache/DefaultCacheProvider.java index 8598a0dd5190..db11fa421985 100644 --- a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/cache/DefaultCacheProvider.java +++ b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/cache/DefaultCacheProvider.java @@ -371,6 +371,17 @@ public Cache createApiKeyCache() { .withMaximumSize(orZeroInTestRun(getActualSize(SIZE_10K)))); } + @Override + public Cache createUserDetailsAuthzCache() { + return registerCache( + this.newBuilder() + .forRegion(Region.userDetailsAuthzCache.name()) + .expireAfterWrite(5, TimeUnit.MINUTES) + .withInitialCapacity((int) getActualSize(SIZE_1K)) + .forceInMemory() + .withMaximumSize(orZeroInTestRun(getActualSize(SIZE_10K)))); + } + @Override public Cache createTeAttributesCache() { return registerCache( diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/authz/InMemoryAuthzVersionStore.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/authz/InMemoryAuthzVersionStore.java new file mode 100644 index 000000000000..5b39e12cd0d5 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/authz/InMemoryAuthzVersionStore.java @@ -0,0 +1,109 @@ +/* + * 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.test.authz; + +import java.util.Collection; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nonnull; +import org.hisp.dhis.user.authz.AuthzVersionStore; + +/** + * In-memory {@link AuthzVersionStore} test double for H2-backed test contexts, where the + * PostgreSQL-only production JDBC store cannot run (feature coverage lives in the Postgres + * integration tests). Mirrors the JDBC contract: missing keys are 0, and each bump (or batch) + * advances the epoch once. The epoch MUST move on bumps: {@code DefaultAuthzService} invalidates + * its per-username cache only on epoch movement, so a frozen store would leak stale principals. + * + * @author Morten Svanæs + */ +public class InMemoryAuthzVersionStore implements AuthzVersionStore { + + private final ConcurrentHashMap userGens = new ConcurrentHashMap<>(); + private final ConcurrentHashMap roleGens = new ConcurrentHashMap<>(); + private final AtomicLong epoch = new AtomicLong(0); + + @Override + public long getEpoch() { + return epoch.get(); + } + + @Override + public long getMaxGen(@Nonnull String userUid, @Nonnull Collection roleUids) { + long max = genOf(userGens, userUid); + for (String roleUid : roleUids) { + max = Math.max(max, genOf(roleGens, roleUid)); + } + return max; + } + + @Override + public void bumpUserGen(@Nonnull String userUid) { + bump(userGens, userUid); + epoch.incrementAndGet(); + } + + @Override + public void bumpRoleGen(@Nonnull String roleUid) { + bump(roleGens, roleUid); + epoch.incrementAndGet(); + } + + @Override + public void bumpUserGens(@Nonnull Collection userUids) { + TreeSet distinct = new TreeSet<>(); + for (String uid : userUids) { + if (uid == null) { + continue; + } + String trimmed = uid.trim(); + if (!trimmed.isEmpty()) { + distinct.add(trimmed); + } + } + if (distinct.isEmpty()) { + return; + } + for (String uid : distinct) { + bump(userGens, uid); + } + epoch.incrementAndGet(); + } + + private static long genOf(ConcurrentHashMap map, String key) { + AtomicLong value = map.get(key); + return value == null ? 0L : value.get(); + } + + private static void bump(ConcurrentHashMap map, String key) { + map.computeIfAbsent(key, k -> new AtomicLong(0)).incrementAndGet(); + } +} diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/config/H2TestConfig.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/config/H2TestConfig.java index 9167d720a2f6..8673458e6c8f 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/config/H2TestConfig.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/config/H2TestConfig.java @@ -45,7 +45,9 @@ import org.hisp.dhis.datasource.model.DbPoolConfig; import org.hisp.dhis.external.conf.ConfigurationKey; import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.test.authz.InMemoryAuthzVersionStore; import org.hisp.dhis.test.h2.H2SqlFunction; +import org.hisp.dhis.user.authz.AuthzVersionStore; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -63,6 +65,19 @@ public DhisConfigurationProvider dhisConfigurationProvider() { return new H2DhisConfigurationProvider(); } + /** + * The production {@link org.hisp.dhis.user.authz.JdbcAuthzVersionStore} SQL is PostgreSQL-only; + * soft-refresh feature coverage runs against Postgres. H2 contexts still need a functional + * epoch/gen double (not a no-op): {@code DefaultAuthzService} invalidates its per-username + * UserDetails cache only when the epoch moves, so a frozen epoch would leak stale principals + * across tests in the same context. + */ + @Bean + @Primary + public AuthzVersionStore authzVersionStore() { + return new InMemoryAuthzVersionStore(); + } + public static class NoOpFlyway {} @Bean diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStoreTest.java new file mode 100644 index 000000000000..115c0ab862c4 --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/authz/JdbcAuthzVersionStoreTest.java @@ -0,0 +1,122 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Set; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +/** + * Validates {@link JdbcAuthzVersionStore} ON CONFLICT SQL against real Postgres. + * + * @author Morten Svanæs + */ +@TestInstance(Lifecycle.PER_CLASS) +@Transactional +class JdbcAuthzVersionStoreTest extends PostgresIntegrationTestBase { + + @Autowired private AuthzVersionStore store; + @Autowired private JdbcTemplate jdbcTemplate; + + @BeforeEach + void wipeStore() { + // Other suites in the shared CI database commit bumps; absolute gen/epoch assertions below + // require a clean slate. Runs inside the test transaction, so it is rolled back afterwards. + jdbcTemplate.update("delete from authz_version"); + } + + @Test + void epochStartsAtZero() { + assertEquals(0L, store.getEpoch()); + } + + @Test + void bumpUserGenAdvancesUserGenAndEpoch() { + long epochBefore = store.getEpoch(); + store.bumpUserGen("userA"); + + assertEquals(epochBefore + 1, store.getEpoch()); + assertEquals(1L, store.getMaxGen("userA", Set.of())); + } + + @Test + void bumpRoleGenIsIndependent() { + store.bumpUserGen("userA"); + store.bumpRoleGen("roleA"); + + assertEquals(1L, store.getMaxGen("userA", Set.of())); + assertEquals(1L, store.getMaxGen("other", Set.of("roleA"))); + assertEquals(1L, store.getMaxGen("userA", Set.of("roleA"))); + } + + @Test + void repeatedBumpsIncrementViaUpsertConflictPath() { + store.bumpUserGen("userB"); + store.bumpUserGen("userB"); + store.bumpUserGen("userB"); + + assertEquals(3L, store.getMaxGen("userB", Set.of())); + assertEquals(3L, store.getEpoch()); + } + + @Test + void bumpUserGensDeduplicatesAndAdvancesEpochOnce() { + long epochBefore = store.getEpoch(); + store.bumpUserGens(List.of("u2", "u1", "u1", "", " ", "u2")); + + assertEquals(epochBefore + 1, store.getEpoch()); + assertEquals(1L, store.getMaxGen("u1", Set.of())); + assertEquals(1L, store.getMaxGen("u2", Set.of())); + } + + @Test + void getMaxGenAcrossUserAndRoles() { + store.bumpUserGen("userC"); // user=1 + store.bumpRoleGen("role1"); // role=1 + store.bumpRoleGen("role2"); // role=1 + store.bumpRoleGen("role2"); // role=2 + + assertEquals(2L, store.getMaxGen("userC", Set.of("role1", "role2"))); + } + + @Test + void getMaxGenUnknownKeysIsZero() { + assertEquals(0L, store.getMaxGen("missing-user", Set.of("missing-role"))); + } +} diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserControllerTest.java index 04eeae212ea6..617f53dc40ac 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/UserControllerTest.java @@ -136,7 +136,7 @@ void afterEach() { } @Test - void updateRolesShouldInvalidateUserSessions() { + void updateUserRolesShouldNotKillSessions() { UserDetails sessionPrincipal = userService.createUserDetails(getAdminUser()); sessionRegistry.registerNewSession("session1", sessionPrincipal); assertFalse(sessionRegistry.getAllSessions(sessionPrincipal, false).isEmpty()); @@ -151,11 +151,13 @@ void updateRolesShouldInvalidateUserSessions() { "[{'op':'add','path':'/userRoles','value':[{'id':'" + roleBID + "'}]}]") .content(HttpStatus.OK); - assertTrue(sessionRegistry.getAllSessions(sessionPrincipal, false).isEmpty()); + // Soft-refresh: role assignment changes no longer kill the user's sessions; the authz + // generation bump is covered by AuthzSoftRefreshHooksTest against Postgres. + assertFalse(sessionRegistry.getAllSessions(sessionPrincipal, false).isEmpty()); } @Test - void updateRolesAuthoritiesShouldInvalidateUserSessions() { + void updateRoleAuthoritiesShouldNotKillSessions() { UserDetails sessionPrincipal = userService.createUserDetails(getAdminUser()); UserRole roleB = createUserRole("ROLE_B", "ALL"); @@ -182,7 +184,9 @@ void updateRolesAuthoritiesShouldInvalidateUserSessions() { + "]") .content(HttpStatus.OK); - assertTrue(sessionRegistry.getAllSessions(sessionPrincipal, false).isEmpty()); + // Soft-refresh: role authority changes no longer kill sessions; the authz generation bump + // is covered by AuthzSoftRefreshHooksTest against Postgres. + assertFalse(sessionRegistry.getAllSessions(sessionPrincipal, false).isEmpty()); } @Test diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/AuthzSoftRefreshHooksTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/AuthzSoftRefreshHooksTest.java new file mode 100644 index 000000000000..2e084399aec4 --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/AuthzSoftRefreshHooksTest.java @@ -0,0 +1,141 @@ +/* + * 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 static org.hisp.dhis.http.HttpAssertions.assertStatus; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase; +import org.hisp.dhis.user.authz.AuthzVersionStore; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +/** + * Proves the bundle-hook and service-path bump wiring end to end through the real API against + * Postgres and the production {@code JdbcAuthzVersionStore}: user, role and group mutations must + * advance the affected generations and the epoch (not just return 2xx). + * + * @author Morten Svanæs + */ +@Transactional +class AuthzSoftRefreshHooksTest extends PostgresControllerIntegrationTestBase { + + @Autowired private AuthzVersionStore authzVersionStore; + + @Test + void groupMembershipBump() { + String adminUid = getAdminUid(); + long epochBefore = authzVersionStore.getEpoch(); + + String groupId = + assertStatus( + HttpStatus.CREATED, + POST("/userGroups", "{'name':'GroupA','users':[{'id':'" + adminUid + "'}]}")); + + assertTrue( + GET("/userGroups/" + groupId + "?fields=users") + .content(HttpStatus.OK) + .toString() + .contains(adminUid), + "group import must persist the posted member"); + long genAfterCreate = authzVersionStore.getMaxGen(adminUid, Set.of()); + assertTrue(authzVersionStore.getEpoch() > epochBefore, "group create must advance the epoch"); + assertTrue(genAfterCreate > 0, "group create with member must bump the member's user gen"); + + assertStatus(HttpStatus.OK, PUT("/userGroups/" + groupId, "{'name':'GroupA','users':[]}")); + + assertTrue( + authzVersionStore.getMaxGen(adminUid, Set.of()) > genAfterCreate, + "membership removal must bump the removed member's user gen"); + } + + @Test + void roleAuthorityChangeBump() { + String roleId = + assertStatus( + HttpStatus.CREATED, + POST("/userRoles", "{'name':'RoleA','authorities':['F_DATAVALUE_ADD']}")); + + long roleGenBefore = authzVersionStore.getMaxGen("nonexistentUid", Set.of(roleId)); + long epochBefore = authzVersionStore.getEpoch(); + + assertStatus( + HttpStatus.OK, + PUT("/userRoles/" + roleId, "{'name':'RoleA','authorities':['F_DATAVALUE_DELETE']}")); + + assertTrue( + authzVersionStore.getMaxGen("nonexistentUid", Set.of(roleId)) > roleGenBefore, + "authority change must bump the role gen"); + assertTrue(authzVersionStore.getEpoch() > epochBefore, "role bump must advance the epoch"); + } + + @Test + void userRoleAssignmentBump() { + String adminUid = getAdminUid(); + String roleId = + assertStatus( + HttpStatus.CREATED, + POST("/userRoles", "{'name':'RoleC','authorities':['F_DATAVALUE_ADD']}")); + + long epochBefore = authzVersionStore.getEpoch(); + long userGenBefore = authzVersionStore.getMaxGen(adminUid, Set.of()); + + assertStatus( + HttpStatus.OK, + PATCH( + "/users/" + adminUid, + "[{'op':'add','path':'/userRoles','value':[{'id':'" + roleId + "'}]}]")); + + assertTrue( + authzVersionStore.getMaxGen(adminUid, Set.of()) > userGenBefore, + "role assignment must bump the user's gen"); + assertTrue(authzVersionStore.getEpoch() > epochBefore, "user bump must advance the epoch"); + } + + @Test + void roleDeleteBump() { + String roleId = + assertStatus( + HttpStatus.CREATED, + POST("/userRoles", "{'name':'RoleB','authorities':['F_DATAVALUE_ADD']}")); + + long epochBefore = authzVersionStore.getEpoch(); + + assertStatus(HttpStatus.OK, DELETE("/userRoles/" + roleId)); + + assertTrue( + authzVersionStore.getMaxGen("nonexistentUid", Set.of(roleId)) > 0, + "role delete must bump the deleted role's gen"); + assertTrue(authzVersionStore.getEpoch() > epochBefore, "role delete must advance the epoch"); + } +} 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..70a59f2ad4af 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 @@ -39,6 +39,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 +57,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 +84,20 @@ 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 freshDetails = authzService.getFreshUserDetails(cachedDetails.getUsername()); + if (freshDetails != null && freshDetails != cachedDetails) { + // identity: same instance == unchanged epoch + ApiTokenAuthenticationToken refreshed = + new ApiTokenAuthenticationToken(cached.getToken(), freshDetails); + apiTokenCache.put(tokenKey, refreshed); + return refreshed; + } + } + return cached; } else { ApiToken apiToken = apiTokenService.getByKey(tokenKey); if (apiToken == null) { @@ -109,6 +127,9 @@ 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. + // Deliberately NOT the memoized authz snapshot: the account flags validated below (disabled, + // locked, expired, 2FA) do not advance the authz epoch when they change, so the snapshot may + // be stale. This runs only on a token cache miss. UserDetails userDetails = userService.createUserDetailsByUsername(createdBy.getUsername()); if (userDetails == null) { throw new ApiTokenAuthenticationException( diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepository.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepository.java new file mode 100644 index 000000000000..045594abcf31 --- /dev/null +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepository.java @@ -0,0 +1,268 @@ +/* + * 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.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.util.Map; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.security.oidc.DhisOidcUser; +import org.hisp.dhis.user.UserDetails; +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.DeferredSecurityContext; +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.web.authentication.switchuser.SwitchUserGrantedAuthority; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; + +/** + * Decorates the session-backed {@link SecurityContextRepository} to soft-refresh the {@link + * UserDetails} principal when its own authz stamp (see {@link UserDetails#getAuthzCheckedEpoch()}) + * is behind the store. The three-way freshness decision lives in {@link + * AuthzService#refreshIfStale}; this class only extracts the principal, swaps it, and persists the + * refreshed context to the session. + * + *

Decorating the repository (instead of adding a filter) makes the refresh session-only by + * construction: JWT, PAT, and other stateless authentications never pass through {@link + * HttpSessionSecurityContextRepository}, so no token-type conversion or accidental session creation + * is possible. Wrapping the {@link DeferredSecurityContext} means the check only runs when the + * request actually accesses authentication. + * + *

Eligibility gates: + * + *

    + *
  • Token type: only {@link UsernamePasswordAuthenticationToken} and {@link + * OAuth2AuthenticationToken} carry a session principal we can rebuild. + *
  • No impersonation: {@link SwitchUserGrantedAuthority} carries the original Authentication; a + * rebuild would drop it. + *
+ * + * @author Morten Svanæs + */ +@Slf4j +public class SoftRefreshSecurityContextRepository implements SecurityContextRepository { + + private final SecurityContextRepository delegate; + private final AuthzService authzService; + + public SoftRefreshSecurityContextRepository( + SecurityContextRepository delegate, AuthzService authzService) { + this.delegate = delegate; + this.authzService = authzService; + } + + @Override + public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) { + return new SoftRefreshDeferredContext(delegate.loadDeferredContext(request), request, this); + } + + @Override + @SuppressWarnings("deprecation") + public SecurityContext loadContext( + org.springframework.security.web.context.HttpRequestResponseHolder requestResponseHolder) { + return delegate.loadContext(requestResponseHolder); + } + + @Override + public void saveContext( + SecurityContext context, HttpServletRequest request, HttpServletResponse response) { + delegate.saveContext(context, request, response); + } + + @Override + public boolean containsContext(HttpServletRequest request) { + return delegate.containsContext(request); + } + + /** Defers the freshness check until the request actually accesses its authentication. */ + private static final class SoftRefreshDeferredContext implements DeferredSecurityContext { + + private final DeferredSecurityContext delegate; + private final HttpServletRequest request; + private final SoftRefreshSecurityContextRepository repository; + + private SecurityContext result; + + private SoftRefreshDeferredContext( + DeferredSecurityContext delegate, + HttpServletRequest request, + SoftRefreshSecurityContextRepository repository) { + this.delegate = delegate; + this.request = request; + this.repository = repository; + } + + @Override + public SecurityContext get() { + if (result == null) { + result = repository.maybeRefresh(delegate.get(), request); + } + return result; + } + + @Override + public boolean isGenerated() { + return delegate.isGenerated(); + } + } + + /** + * Returns {@code context} unchanged when the principal's stamp is current (or the context is not + * eligible), otherwise a new context with the re-stamped or rebuilt principal, persisted to the + * session so subsequent requests load it directly. + */ + private SecurityContext maybeRefresh(SecurityContext context, HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null || context == null) { + return context; + } + Authentication auth = context.getAuthentication(); + if (!isEligible(auth)) { + return context; + } + UserDetails current = extractUserDetails(auth); + if (current == null) { + return context; + } + UserDetails result = authzService.refreshIfStale(current); + if (result == current) { + return context; // stamp current: the dominant path, one epoch read + } + Authentication replacement = rebuildAuthentication(auth, result); + if (replacement == null) { + return context; + } + SecurityContext refreshed = SecurityContextHolder.createEmptyContext(); + refreshed.setAuthentication(replacement); + // Equivalent of HttpSessionSecurityContextRepository.saveContextInHttpSession: under + // requireExplicitSave, SecurityContextHolderFilter never saves, so the refresh persists + // itself (and its new stamp) for subsequent requests. + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, refreshed); + log.debug( + "Soft-refreshed session UserDetails for {} (epoch {} -> {})", + current.getUsername(), + current.getAuthzCheckedEpoch(), + result.getAuthzCheckedEpoch()); + return refreshed; + } + + /** + * Impersonation carries the original Authentication inside {@link SwitchUserGrantedAuthority} + * which a rebuild would drop; only session-native token types are rebuildable. + */ + private static boolean isEligible(@CheckForNull Authentication auth) { + return auth != null + && auth.isAuthenticated() + && (auth instanceof UsernamePasswordAuthenticationToken + || auth instanceof OAuth2AuthenticationToken) + && auth.getAuthorities().stream().noneMatch(SwitchUserGrantedAuthority.class::isInstance); + } + + @CheckForNull + private static UserDetails extractUserDetails(@Nonnull Authentication auth) { + Object principal = auth.getPrincipal(); + if (principal instanceof DhisOidcUser oidcUser) { + org.springframework.security.core.userdetails.UserDetails nested = oidcUser.getUser(); + if (nested instanceof UserDetails dhisUserDetails) { + return dhisUserDetails; + } + return null; + } + if (principal instanceof UserDetails userDetails) { + return userDetails; + } + return null; + } + + @CheckForNull + private static Authentication rebuildAuthentication( + @Nonnull Authentication existing, @Nonnull UserDetails fresh) { + if (existing instanceof OAuth2AuthenticationToken oauth2 + && oauth2.getPrincipal() instanceof DhisOidcUser oidcUser) { + Map attributes = oidcUser.getAttributes(); + if (attributes.isEmpty()) { + return null; + } + DhisOidcUser newPrincipal = + new DhisOidcUser( + fresh, attributes, resolveNameAttributeKey(oidcUser), oidcUser.getIdToken()); + OAuth2AuthenticationToken token = + new OAuth2AuthenticationToken( + newPrincipal, fresh.getAuthorities(), oauth2.getAuthorizedClientRegistrationId()); + token.setDetails(existing.getDetails()); + return token; + } + if (existing instanceof UsernamePasswordAuthenticationToken + && existing.getPrincipal() instanceof UserDetails) { + UsernamePasswordAuthenticationToken token = + UsernamePasswordAuthenticationToken.authenticated( + fresh, existing.getCredentials(), fresh.getAuthorities()); + token.setDetails(existing.getDetails()); + return token; + } + return null; + } + + /** + * {@link org.springframework.security.oauth2.core.user.DefaultOAuth2User} requires the name + * attribute key to exist in attributes. {@link DhisOidcUser} does not override {@code getName()}, + * so the value returned by {@code getName()} is the original name attribute's value and can be + * used to recover the key. + */ + @Nonnull + static String resolveNameAttributeKey(@Nonnull DhisOidcUser oidcUser) { + Map attributes = oidcUser.getAttributes(); + String name = null; + try { + name = oidcUser.getName(); + } catch (RuntimeException ignored) { + // odd principal states; fall through to defaults + } + if (name != null) { + for (Map.Entry entry : attributes.entrySet()) { + if (entry.getValue() != null && name.equals(String.valueOf(entry.getValue()))) { + return entry.getKey(); + } + } + } + if (attributes.containsKey("sub")) { + return "sub"; + } + return attributes.keySet().iterator().next(); + } +} 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..dce5bfe5d424 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 @@ -57,6 +57,7 @@ import org.hisp.dhis.security.oidc.DhisOidcProviderRepository; import org.hisp.dhis.security.spring2fa.TwoFactorAuthenticationProvider; import org.hisp.dhis.security.spring2fa.TwoFactorWebAuthenticationDetailsSource; +import org.hisp.dhis.user.authz.AuthzService; import org.hisp.dhis.webapi.filter.CspFilter; import org.hisp.dhis.webapi.filter.DhisCorsProcessor; import org.hisp.dhis.webapi.filter.SessionTimeoutHeaderFilter; @@ -65,6 +66,7 @@ import org.hisp.dhis.webapi.security.Http401LoginUrlAuthenticationEntryPoint; import org.hisp.dhis.webapi.security.apikey.ApiTokenAuthManager; import org.hisp.dhis.webapi.security.apikey.Dhis2ApiTokenFilter; +import org.hisp.dhis.webapi.security.authz.SoftRefreshSecurityContextRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; @@ -80,11 +82,14 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.context.DelegatingSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; @@ -152,6 +157,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,7 +272,6 @@ protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { configureOAuthTokenFilters(http); http.addFilterAfter(new SessionTimeoutHeaderFilter(), SessionManagementFilter.class); - setHttpHeaders(http); return http.build(); @@ -293,9 +299,18 @@ public WebSecurityCustomizer webSecurityCustomizer() { } private void configureMatchers(HttpSecurity http) throws Exception { + // Replicates the configurer's default repository pair, with the HttpSession delegate + // decorated for authz soft-refresh (session-only by construction; JWT/PAT never load + // through it). http.securityContext( - httpSecuritySecurityContextConfigurer -> - httpSecuritySecurityContextConfigurer.requireExplicitSave(true)); + securityContext -> + securityContext + .requireExplicitSave(true) + .securityContextRepository( + new DelegatingSecurityContextRepository( + new RequestAttributeSecurityContextRepository(), + new SoftRefreshSecurityContextRepository( + new HttpSessionSecurityContextRepository(), authzService)))); Set providerIds = dhisOidcProviderRepository.getAllRegistrationId(); http.authorizeHttpRequests( @@ -440,7 +455,8 @@ public O postProcess(O filter) { http.sessionManagement( session -> session - .sessionFixation(sessionFixation -> sessionFixation.migrateSession()) + .sessionFixation( + SessionManagementConfigurer.SessionFixationConfigurer::migrateSession) .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .enableSessionUrlRewriting(false) .maximumSessions( diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepositoryTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepositoryTest.java new file mode 100644 index 000000000000..8f745d5d6f41 --- /dev/null +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/security/authz/SoftRefreshSecurityContextRepositoryTest.java @@ -0,0 +1,280 @@ +/* + * 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 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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.hisp.dhis.security.apikey.ApiToken; +import org.hisp.dhis.security.apikey.ApiTokenAuthenticationToken; +import org.hisp.dhis.security.oidc.DhisOidcUser; +import org.hisp.dhis.user.UserDetails; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.DeferredSecurityContext; +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.authentication.switchuser.SwitchUserGrantedAuthority; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; + +/** + * Unit tests for {@link SoftRefreshSecurityContextRepository}, using a real {@link + * HttpSessionSecurityContextRepository} delegate so contexts load the way they do in production. + * + * @author Morten Svanæs + */ +@ExtendWith(MockitoExtension.class) +class SoftRefreshSecurityContextRepositoryTest { + + @Mock private AuthzService authzService; + + private SoftRefreshSecurityContextRepository repository; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + repository = + new SoftRefreshSecurityContextRepository( + new HttpSessionSecurityContextRepository(), authzService); + request = new MockHttpServletRequest("GET", "/api/me"); + } + + @Test + void noSessionPerformsZeroAuthzServiceCalls() { + repository.loadDeferredContext(request).get(); + + verifyNoInteractions(authzService); + } + + @Test + void loadWithoutAccessPerformsZeroAuthzServiceCalls() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + storeContext( + session, + UsernamePasswordAuthenticationToken.authenticated( + userDetails("alice", "F_USER"), "n/a", List.of())); + + repository.loadDeferredContext(request); // never call get() + + verifyNoInteractions(authzService); + } + + @Test + void patTokenContextIsUntouched() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails principal = userDetails("pat-user", "F_PAT"); + ApiTokenAuthenticationToken pat = new ApiTokenAuthenticationToken(new ApiToken(), principal); + pat.setAuthenticated(true); + SecurityContext stored = storeContext(session, pat); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + assertSame(stored, loaded); + verifyNoInteractions(authzService); + } + + @Test + void switchUserAuthorityIsSkipped() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + Authentication original = + UsernamePasswordAuthenticationToken.authenticated( + userDetails("admin", "ALL"), "n/a", List.of(new SimpleGrantedAuthority("ALL"))); + SwitchUserGrantedAuthority switchAuth = + new SwitchUserGrantedAuthority("F_PREVIOUS_IMPERSONATOR_AUTHORITY", original); + UsernamePasswordAuthenticationToken auth = + UsernamePasswordAuthenticationToken.authenticated( + userDetails("alice", "F_USER"), + "n/a", + List.of(switchAuth, new SimpleGrantedAuthority("F_USER"))); + SecurityContext stored = storeContext(session, auth); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + assertSame(stored, loaded); + verifyNoInteractions(authzService); + } + + @Test + void currentStampReturnsContextUntouched() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails current = userDetails("alice", "F_USER"); + SecurityContext stored = storeUsernamePasswordContext(session, current); + + when(authzService.refreshIfStale(current)).thenReturn(current); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + assertSame(stored, loaded); + verify(authzService, times(1)).refreshIfStale(current); + } + + @Test + void restampedCopyReplacesPrincipalAndPersists() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails current = userDetails("alice", "F_USER"); + SecurityContext stored = storeUsernamePasswordContext(session, current); + // Epoch moved but alice's gens did not: the service hands back a re-stamped copy. + UserDetails restamped = userDetails("alice", "F_USER"); + + when(authzService.refreshIfStale(current)).thenReturn(restamped); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + assertNotSame(stored, loaded); + assertSame(restamped, loaded.getAuthentication().getPrincipal()); + // The copy persists itself so the next request takes the epoch fast path. + assertSame(loaded, session.getAttribute(SPRING_SECURITY_CONTEXT_KEY)); + } + + @Test + void staleStampRebuildsAndPersistsRefreshedContext() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails stale = userDetails("alice", "F_OLD"); + SecurityContext stored = storeUsernamePasswordContext(session, stale); + UserDetails fresh = userDetails("alice", "F_NEW"); + + when(authzService.refreshIfStale(stale)).thenReturn(fresh); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + assertNotSame(stored, loaded); + Authentication after = loaded.getAuthentication(); + assertInstanceOf(UsernamePasswordAuthenticationToken.class, after); + assertSame(fresh, after.getPrincipal()); + assertTrue(after.getAuthorities().stream().anyMatch(a -> "F_NEW".equals(a.getAuthority()))); + // The refresh persists itself: subsequent requests must load the refreshed context. + assertSame(loaded, session.getAttribute(SPRING_SECURITY_CONTEXT_KEY)); + } + + @Test + void oidcTokenRebuildsDhisOidcUserPrincipal() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails stale = userDetails("oidc-user", "F_OLD"); + UserDetails fresh = userDetails("oidc-user", "F_NEW"); + Map attributes = Map.of("sub", "subject-1", "email", "u@example.com"); + OidcIdToken idToken = + new OidcIdToken( + "token-value", + Instant.now().minusSeconds(60), + Instant.now().plusSeconds(3600), + attributes); + DhisOidcUser oidcUser = new DhisOidcUser(stale, attributes, "sub", idToken); + OAuth2AuthenticationToken oauth2 = + new OAuth2AuthenticationToken(oidcUser, stale.getAuthorities(), "google"); + storeContext(session, oauth2); + + // The repository must check the UNWRAPPED principal: DhisOidcUser itself carries no stamp. + when(authzService.refreshIfStale(stale)).thenReturn(fresh); + + SecurityContext loaded = repository.loadDeferredContext(request).get(); + + Authentication after = loaded.getAuthentication(); + assertInstanceOf(OAuth2AuthenticationToken.class, after); + OAuth2AuthenticationToken replaced = (OAuth2AuthenticationToken) after; + assertEquals("google", replaced.getAuthorizedClientRegistrationId()); + assertInstanceOf(DhisOidcUser.class, replaced.getPrincipal()); + DhisOidcUser newPrincipal = (DhisOidcUser) replaced.getPrincipal(); + assertSame(fresh, newPrincipal.getUser()); + assertNotSame(oidcUser, newPrincipal); + assertSame(loaded, session.getAttribute(SPRING_SECURITY_CONTEXT_KEY)); + } + + @Test + void repeatedAccessIsCachedAndChecksOnce() { + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + UserDetails stale = userDetails("alice", "F_OLD"); + storeUsernamePasswordContext(session, stale); + UserDetails fresh = userDetails("alice", "F_NEW"); + + when(authzService.refreshIfStale(stale)).thenReturn(fresh); + + DeferredSecurityContext deferred = repository.loadDeferredContext(request); + SecurityContext first = deferred.get(); + SecurityContext second = deferred.get(); + + assertSame(first, second); + verify(authzService, times(1)).refreshIfStale(stale); + } + + private static SecurityContext storeUsernamePasswordContext( + MockHttpSession session, UserDetails principal) { + return storeContext( + session, + UsernamePasswordAuthenticationToken.authenticated( + principal, "n/a", principal.getAuthorities())); + } + + private static SecurityContext storeContext(MockHttpSession session, Authentication auth) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); + return context; + } + + private static UserDetails userDetails(String username, String authority) { + return UserDetails.empty() + .uid("uid-" + username) + .username(username) + .authorities(List.of(new SimpleGrantedAuthority(authority))) + .allAuthorities(Set.of(authority)) + .build(); + } +}