Skip to content

Commit cf053d4

Browse files
committed
feat: soft-refresh UserDetails via authz epoch and generation stamps
Replace role/user-driven mass SessionRegistry.expireNow with a JDBC-backed two-level invalidation scheme: - authz_version table (Flyway V2_44_17) holds per-user ('user', <uid>) and per-role ('role', <uid>) generation stamps plus a single global epoch row; every bump advances the epoch in the same transaction as the entity change, so readers can never observe a stamp without its data. The store SQL is PostgreSQL-only like production; H2 test contexts run against an in-memory store wired in H2TestConfig, and feature coverage runs against Postgres. - Freshness stamps (checked epoch + effective gen) live on the immutable UserDetailsImpl snapshot itself, so sessions, JWT, and PAT converge on one three-way check in AuthzService.refreshIfStale: epoch fast path, gen re-stamp path, rebuild path. Gen stamps are validated by a second epoch read after the build; if the epoch moved mid-build the gen is stamped 0 (unknown), failing safe. - SoftRefreshSecurityContextRepository decorates the HttpSession delegate of the standard security context repository pair: it extracts the session principal, asks refreshIfStale, and persists the re-stamped or rebuilt context. Decorating the repository makes the refresh session-only by construction (JWT, PAT, and stateless authentications never load through it), skips impersonated sessions, and defers the check until a request actually accesses its authentication. No logout, no cookie change. - AuthzService memoizes stamped snapshots per username (stamp-before-build invariant) with striped single-flight rebuilds; JWT and PAT share the same cache and gain the gen fast path: bumps for unrelated users re-stamp instead of rebuilding. - UserDetailsImpl serialVersionUID is pinned to the pre-stamp shape, so sessions serialized by earlier releases deserialize with stamps 0 and take one soft refresh instead of a forced re-login; no release-boundary session drain is needed (guarded by UserDetailsImplSerializationTest). - Role authority/restriction edits and role deletion bump one role key, O(1) regardless of member count. User role/org-unit changes and group membership deltas (import hook owning side + service paths) bump only affected users. - Password change, disable, lock, and expiry keep hard session invalidation. PAT account-flag validation builds fresh at token cache-miss since flag changes do not advance the epoch.
1 parent c3320e5 commit cf053d4

29 files changed

Lines changed: 2232 additions & 49 deletions

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/cache/CacheProvider.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ public interface CacheProvider {
9696

9797
<V> Cache<V> createApiKeyCache();
9898

99+
<V> Cache<V> createUserDetailsAuthzCache();
100+
99101
<V> Cache<V> createTeAttributesCache();
100102

101103
<V> Cache<V> createProgramTeAttributesCache();

dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/cache/Region.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,6 @@ public enum Region {
6868
dataIntegrityDetailsCache,
6969
queryAliasCache,
7070
corsWhitelistCache,
71-
notificationTemplateCache
71+
notificationTemplateCache,
72+
userDetailsAuthzCache
7273
}

dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,23 @@ static UserDetails createUserDetails(
286286
@Nonnull
287287
Set<String> getUserRoleIds();
288288

289+
/**
290+
* Soft-refresh stamp: global authz epoch read before this snapshot was built. 0 = unknown; the
291+
* next soft-refresh check verifies the generation and re-stamps or rebuilds. Only {@link
292+
* UserDetailsImpl} carries real stamps.
293+
*/
294+
default long getAuthzCheckedEpoch() {
295+
return 0L;
296+
}
297+
298+
/**
299+
* Soft-refresh stamp: effective authz generation (max of user and role gens) this snapshot
300+
* reflects. 0 = unknown, which fails safe (extra refresh, never stale).
301+
*/
302+
default long getAuthzGen() {
303+
return 0L;
304+
}
305+
289306
boolean canModifyUser(User userToModify);
290307

291308
boolean isExternalAuth();

dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,20 @@
4242
import org.springframework.security.core.GrantedAuthority;
4343

4444
@Getter
45-
@Builder
45+
@Builder(toBuilder = true)
4646
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
4747
@Slf4j
4848
public class UserDetailsImpl implements UserDetails {
4949

50+
/**
51+
* Pinned to the pre-stamp shape of this class (release that introduced soft-refresh). Sessions
52+
* serialized by earlier code then deserialize cleanly with {@code authzCheckedEpoch} and {@code
53+
* authzGen} defaulting to 0, which the soft-refresh check treats as "unknown": one refresh on the
54+
* next request instead of a failed deserialization and a forced re-login. Do NOT let this value
55+
* drift; guarded by {@code UserDetailsImplSerializationTest}.
56+
*/
57+
private static final long serialVersionUID = -6804263748578733471L;
58+
5059
private final String uid;
5160
@Setter private Long id;
5261
private final String code;
@@ -78,6 +87,18 @@ public class UserDetailsImpl implements UserDetails {
7887
@Nonnull private final Set<Long> managedGroupLongIds;
7988
@Nonnull private final Set<Long> userRoleLongIds;
8089

90+
/**
91+
* Soft-refresh stamp: global authz epoch read BEFORE this snapshot was built. 0 = unknown (e.g.
92+
* login-built or deserialized from a pre-stamp release); the next check verifies the gen.
93+
*/
94+
private final long authzCheckedEpoch;
95+
96+
/**
97+
* Soft-refresh stamp: effective authz generation (max of user and role gens) this snapshot
98+
* reflects. 0 = unknown, which fails SAFE (one extra refresh, never stale).
99+
*/
100+
private final long authzGen;
101+
81102
@Override
82103
public boolean canModifyUser(User other) {
83104
if (other == null) {
@@ -125,4 +146,9 @@ public boolean isAuthorized(String auth) {
125146
public boolean isAuthorized(@Nonnull Authorities auth) {
126147
return isAuthorized(auth.toString());
127148
}
149+
150+
/** Copy with new soft-refresh stamps; all data fields unchanged. */
151+
public UserDetailsImpl withAuthzStamp(long checkedEpoch, long gen) {
152+
return toBuilder().authzCheckedEpoch(checkedEpoch).authzGen(gen).build();
153+
}
128154
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.user.authz;
31+
32+
import java.util.Collection;
33+
import javax.annotation.CheckForNull;
34+
import javax.annotation.Nonnull;
35+
import org.hisp.dhis.user.UserDetails;
36+
37+
/**
38+
* Soft-refresh facade over authz generation stamps and cached UserDetails snapshots.
39+
*
40+
* @author Morten Svanæs
41+
*/
42+
public interface AuthzService {
43+
44+
/**
45+
* Epoch-validated, cached, immutable snapshot for username, stamped with the authz epoch read
46+
* before the snapshot was built and the effective generation it reflects (see {@link
47+
* UserDetails#getAuthzCheckedEpoch()}). Returns a snapshot that reflects every authz change
48+
* committed up to the epoch value read at call entry. Null if user unknown.
49+
*/
50+
@CheckForNull
51+
UserDetails getFreshUserDetails(@Nonnull String username);
52+
53+
/**
54+
* Three-way freshness check against the principal's own authz stamp. Returns:
55+
*
56+
* <ul>
57+
* <li>the same instance: stamp is current (or the user is unknown) — nothing to do;
58+
* <li>a re-stamped copy: the epoch moved but this principal's generations did not — persist the
59+
* copy so the next check takes the epoch fast path;
60+
* <li>a rebuilt snapshot: this principal's generations moved — persist and use it.
61+
* </ul>
62+
*
63+
* <p>Identity comparison with the argument tells the caller whether anything must be persisted.
64+
*/
65+
@Nonnull
66+
UserDetails refreshIfStale(@Nonnull UserDetails principal);
67+
68+
void bumpUserAuthz(@Nonnull String userUid);
69+
70+
void bumpRoleAuthz(@Nonnull String roleUid);
71+
72+
void bumpUsers(@Nonnull Collection<String> userUids);
73+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.user.authz;
31+
32+
import java.util.Collection;
33+
import javax.annotation.Nonnull;
34+
35+
/**
36+
* Generation stamps that drive UserDetails soft-refresh.
37+
*
38+
* <p>Every bump advances the global epoch. Bumps participate in the caller's ambient transaction
39+
* (JdbcTemplate joins it), so a reader can never observe a gen/epoch value without also seeing the
40+
* committed data that caused it.
41+
*
42+
* @author Morten Svanæs
43+
*/
44+
public interface AuthzVersionStore {
45+
/** Global epoch; advanced by every bump. Missing row = 0. */
46+
long getEpoch();
47+
48+
/** max(user gen for userUid, role gens for roleUids); missing keys count as 0. */
49+
long getMaxGen(@Nonnull String userUid, @Nonnull Collection<String> roleUids);
50+
51+
void bumpUserGen(@Nonnull String userUid);
52+
53+
void bumpRoleGen(@Nonnull String roleUid);
54+
55+
/** Bumps each distinct uid once and the epoch once. */
56+
void bumpUserGens(@Nonnull Collection<String> userUids);
57+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.user;
31+
32+
import static org.junit.jupiter.api.Assertions.assertEquals;
33+
import static org.junit.jupiter.api.Assertions.assertNotSame;
34+
35+
import java.io.ObjectStreamClass;
36+
import java.util.Set;
37+
import org.junit.jupiter.api.Test;
38+
39+
/**
40+
* Guards the session-compatibility contract of {@link UserDetailsImpl}: the serialVersionUID is
41+
* pinned to the pre-authz-stamp shape of the class, so HTTP sessions serialized by earlier releases
42+
* (Redis / persisted container sessions) deserialize cleanly with stamps defaulting to 0 — one soft
43+
* refresh on the next request instead of a forced re-login. If this test fails you changed the
44+
* serialized shape; do NOT re-pin without deciding the upgrade story.
45+
*
46+
* @author Morten Svanæs
47+
*/
48+
class UserDetailsImplSerializationTest {
49+
50+
@Test
51+
void serialVersionUidIsPinnedToPreStampShape() {
52+
assertEquals(
53+
-6804263748578733471L,
54+
ObjectStreamClass.lookup(UserDetailsImpl.class).getSerialVersionUID());
55+
}
56+
57+
@Test
58+
void withAuthzStampCopiesDataAndReplacesStamps() {
59+
UserDetails original =
60+
UserDetails.empty()
61+
.username("alice")
62+
.uid("u1")
63+
.userRoleIds(Set.of("r1"))
64+
.authzCheckedEpoch(3L)
65+
.authzGen(2L)
66+
.build();
67+
68+
UserDetailsImpl stamped = ((UserDetailsImpl) original).withAuthzStamp(7L, 5L);
69+
70+
assertNotSame(original, stamped);
71+
assertEquals("alice", stamped.getUsername());
72+
assertEquals("u1", stamped.getUid());
73+
assertEquals(Set.of("r1"), stamped.getUserRoleIds());
74+
assertEquals(7L, stamped.getAuthzCheckedEpoch());
75+
assertEquals(5L, stamped.getAuthzGen());
76+
}
77+
78+
@Test
79+
void defaultStampsAreZeroMeaningUnknown() {
80+
UserDetails details = UserDetails.empty().username("bob").uid("u2").build();
81+
82+
assertEquals(0L, details.getAuthzCheckedEpoch());
83+
assertEquals(0L, details.getAuthzGen());
84+
}
85+
}

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/jwt/Dhis2JwtAuthenticationManagerResolver.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.hisp.dhis.security.oidc.DhisOidcProviderRepository;
4545
import org.hisp.dhis.user.UserDetails;
4646
import org.hisp.dhis.user.UserService;
47+
import org.hisp.dhis.user.authz.AuthzService;
4748
import org.springframework.beans.factory.annotation.Autowired;
4849
import org.springframework.core.convert.converter.Converter;
4950
import org.springframework.security.authentication.AuthenticationManager;
@@ -109,6 +110,7 @@ public class Dhis2JwtAuthenticationManagerResolver
109110
@Autowired private DhisOidcProviderRepository clientRegistrationRepository;
110111
@Autowired private Dhis2OAuth2ClientService oAuth2ClientService;
111112
@Autowired private UserService userService;
113+
@Autowired private AuthzService authzService;
112114

113115
private final Map<String, AuthenticationManager> authenticationManagers =
114116
new ConcurrentHashMap<>();
@@ -218,7 +220,7 @@ private Converter<Jwt, DhisJwtAuthenticationToken> getTokenConverter(
218220
String mappingValue = jwt.getClaim(mappingClaimKey);
219221
UserDetails currentUserDetails =
220222
switch (mappingClaimKey) {
221-
case "username" -> userService.createUserDetailsByUsername(mappingValue);
223+
case "username" -> authzService.getFreshUserDetails(mappingValue);
222224
case "email" -> userService.createUserDetailsByOpenId(mappingValue);
223225
default -> throw new InvalidBearerTokenException("Invalid mapping claim");
224226
};

0 commit comments

Comments
 (0)