• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmrs / openmrs-core / 37033370540

02 Oct 2026 04:21PM UTC coverage: 66.43% (+0.1%) from 66.293%
37033370540

push

github

ibacher
TRUNK-6810: Extend cluster-safety to RolePrivilegeCache (#6627)

281 of 297 new or added lines in 9 files covered. (94.61%)

2 existing lines in 1 file now uncovered.

25927 of 39029 relevant lines covered (66.43%)

0.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.26
/api/src/main/java/org/openmrs/api/cache/RolePrivilegeCache.java
1
/**
2
 * This Source Code Form is subject to the terms of the Mozilla Public License,
3
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
4
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
5
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
6
 *
7
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
8
 * graphic logo is a trademark of OpenMRS Inc.
9
 */
10
package org.openmrs.api.cache;
11

12
import java.util.HashSet;
13
import java.util.Objects;
14
import java.util.Set;
15
import java.util.concurrent.CompletableFuture;
16
import java.util.concurrent.ConcurrentHashMap;
17
import java.util.concurrent.ConcurrentMap;
18
import java.util.concurrent.ExecutionException;
19
import java.util.concurrent.Future;
20
import java.util.function.Function;
21

22
import org.infinispan.Cache;
23
import org.infinispan.spring.embedded.provider.SpringEmbeddedCacheManager;
24
import org.openmrs.Privilege;
25
import org.openmrs.Role;
26
import org.openmrs.api.APIException;
27
import org.openmrs.api.context.Context;
28
import org.openmrs.api.context.Daemon;
29
import org.openmrs.api.db.UserDAO;
30
import org.openmrs.util.RoleConstants;
31
import org.slf4j.Logger;
32
import org.slf4j.LoggerFactory;
33
import org.springframework.beans.factory.DisposableBean;
34
import org.springframework.beans.factory.annotation.Autowired;
35
import org.springframework.beans.factory.annotation.Qualifier;
36
import org.springframework.context.ApplicationListener;
37
import org.springframework.context.event.ContextRefreshedEvent;
38
import org.springframework.stereotype.Component;
39

40
/**
41
 * Per-role cache of flattened privilege resolutions, so privilege and superuser checks need not
42
 * re-expand a user's role graph on every call. The cached value for a role is an immutable
43
 * {@link RolePrivileges} covering that role and its entire inherited closure.
44
 * <p>
45
 * On a miss the closure is computed from a freshly loaded copy of the role, never the
46
 * caller-supplied instance, which may be a stale role graph held by a long-lived
47
 * {@code UserContext}. The role is loaded in a daemon thread, because loading it through the
48
 * secured {@code UserService} requires {@code Get Roles}, the very kind of check being resolved.
49
 * The caller waits for the load, and concurrent misses for the same role share one. A role absent
50
 * from the database grants nothing.
51
 * <p>
52
 * If the load fails, the caller is interrupted while waiting for it, or the cache was evicted while
53
 * the caller waited, so that the load may have read the role from before a change, the role is
54
 * instead read once through the caller's own session, without caching it. That read sees committed
55
 * data and the caller's own changes, but a role the session has already loaded is served as the
56
 * session first loaded it. If that read fails too, the role grants nothing, so a privilege check
57
 * denies rather than throws.
58
 * <p>
59
 * The daemon only sees committed data. Evictions go through {@link CacheInvalidation}, so a load
60
 * only keeps its entry if nothing was evicted, on any node, while it ran, and a miss never waits on
61
 * a load that started before the latest eviction.
62
 * <p>
63
 * Within a transaction, the eviction is applied once, when the transaction completes. Until then
64
 * the transaction resolves roles through its own session, without the cache, so that it sees its
65
 * own changes, while other transactions keep reading the committed ones. Once a transaction that
66
 * changed a role or privilege has completed, privileges are therefore never served from before that
67
 * change, except on a node that missed the eviction while cut off from the cluster.
68
 * <p>
69
 * {@link org.openmrs.api.UserService} clears the cache when it saves or purges a role or privilege,
70
 * and {@link org.openmrs.api.db.hibernate.RolePrivilegeCacheInterceptor} clears it whenever
71
 * Hibernate flushes a change to one, which covers code that changes a loaded {@link Role} or
72
 * {@link Privilege} directly. Changes to the tables made outside Hibernate, for example with SQL,
73
 * are not detected, but the {@code role-privileges} cache template's lifespan limits how long they
74
 * can be served stale.
75
 *
76
 * @since 2.8.9
77
 */
78
@Component("rolePrivilegeCache")
79
public class RolePrivilegeCache implements ApplicationListener<ContextRefreshedEvent>, DisposableBean {
80

81
        private static final Logger log = LoggerFactory.getLogger(RolePrivilegeCache.class);
1 ✔
82

83
        public static final String CACHE_NAME = "rolePrivileges";
84

85
        /**
86
         * Capability token issued by {@link Daemon}, letting this component launch a daemon thread to load
87
         * roles with full trust.
88
         */
89
        private static volatile Daemon.CallerKey daemonCallerKey;
90

91
        private final SpringEmbeddedCacheManager cacheManager;
92

93
        private final UserDAO dao;
94

95
        /**
96
         * Runs a load on another thread, returning a future for the thread's task, or null if there is
97
         * none. The task may fail without running the load, for example if a daemon cannot open a session.
98
         */
99
        private final Function<Runnable, Future<?>> backgroundRunner;
100

101
        /** Loads a role by name on the background thread, seeing only committed data. */
102
        private final Function<String, Role> committedRoleLoader;
103

104
        /** Loads in progress, by normalized role name, so concurrent misses share a single load. */
105
        private final ConcurrentMap<String, Load> loads = new ConcurrentHashMap<>();
1 ✔
106

107
        private final CacheInvalidation invalidation;
108

109
        @Autowired
110
        public RolePrivilegeCache(@Qualifier("apiCacheManager") SpringEmbeddedCacheManager cacheManager, UserDAO dao) {
111
                // daemon threads skip authorization, so the secured UserService can load the role
112
                this(cacheManager, dao, task -> Daemon.runNewDaemonTask(task, daemonCallerKey()),
1 ✔
113
                        roleName -> Context.getUserService().getRole(roleName));
1 ✔
114
        }
1 ✔
115

116
        RolePrivilegeCache(SpringEmbeddedCacheManager cacheManager, UserDAO dao, Function<Runnable, Future<?>> backgroundRunner,
117
            Function<String, Role> committedRoleLoader) {
1 ✔
118
                this.cacheManager = cacheManager;
1 ✔
119
                this.dao = dao;
1 ✔
120
                this.backgroundRunner = backgroundRunner;
1 ✔
121
                this.committedRoleLoader = committedRoleLoader;
1 ✔
122

123
                this.invalidation = new CacheInvalidation("the role privilege cache", this::getCache,
1 ✔
124
                        cacheManager.getNativeCacheManager());
1 ✔
125
        }
1 ✔
126

127
        /**
128
         * Receives the {@link Daemon} caller key. Called only by {@link Daemon} during its initialization.
129
         *
130
         * @param callerKey the caller key issued by {@link Daemon}
131
         */
132
        public static void setDaemonCallerKey(Daemon.CallerKey callerKey) {
133
                if (callerKey != null && daemonCallerKey == null) {
1 ✔
134
                        daemonCallerKey = callerKey;
1 ✔
135
                }
136
        }
1 ✔
137

138
        private static Daemon.CallerKey daemonCallerKey() {
139
                if (daemonCallerKey == null) {
1 ✔
140
                        // Guarantee Daemon has initialized and therefore handed us the key, regardless of the order in
141
                        // which the two classes were first loaded.
142
                        Daemon.ensureInitialized();
×
143
                }
144
                return daemonCallerKey;
1 ✔
145
        }
146

147
        /**
148
         * Returns the flattened privileges for the given role, loading and caching them on a miss. Never
149
         * returns {@code null} and never throws; a role that cannot be loaded grants nothing.
150
         *
151
         * @param role the directly assigned role to resolve
152
         * @return the flattened privilege closure for the role
153
         */
154
        public RolePrivileges getRolePrivileges(Role role) {
155
                if (role == null || role.getRole() == null) {
1 ✔
156
                        return new RolePrivileges(new HashSet<>(), false);
×
157
                }
158

159
                if (invalidation.isWrittenInCurrentTransaction(null)) {
1 ✔
160
                        return resolveInCurrentSession(role.getRole(), null);
1 ✔
161
                }
162

163
                Cache<Object, Object> cache = getCache();
1 ✔
164
                String key = RolePrivileges.normalize(role.getRole());
1 ✔
165
                if (cache != null) {
1 ✔
166
                        Object cached = cache.get(key);
1 ✔
167
                        if (cached instanceof RolePrivileges) {
1 ✔
168
                                return (RolePrivileges) cached;
1 ✔
169
                        }
170
                }
171

172
                Load load = startOrJoinLoad(key, role.getRole(), cache);
1 ✔
173
                RolePrivileges loaded;
174
                try {
175
                        loaded = await(key, load);
1 ✔
176
                } catch (APIException e) {
1 ✔
177
                        // the daemon may have failed for want of a connection the caller already holds, or the caller
178
                        // may have been interrupted
179
                        log.warn("Could not load the privileges of role {}; reading it through the caller's session", key, e);
1 ✔
180
                        return resolveInCurrentSession(role.getRole(), e);
1 ✔
181
                }
1 ✔
182

183
                if (cache != null && !CacheInvalidation.isCurrent(cache, load.generation)) {
1 ✔
184
                        return resolveInCurrentSession(role.getRole(), null);
1 ✔
185
                }
186
                return loaded;
1 ✔
187
        }
188

189
        /**
190
         * Evicts every role. Must be called whenever a role or privilege is saved or purged; closures span
191
         * inherited roles, so a change to one role can affect any entry.
192
         * <p>
193
         * Within a transaction the cache is cleared once, when the transaction completes, whether it
194
         * commits or rolls back, and until then the transaction resolves roles without the cache. Outside
195
         * one it is cleared immediately.
196
         */
197
        public void clear() {
198
                invalidation.invalidate(CacheInvalidation.ALL);
1 ✔
199
        }
1 ✔
200

201
        @Override
202
        public void destroy() {
203
                invalidation.close();
1 ✔
204
        }
1 ✔
205

206
        /**
207
         * Clears the cache whenever the application context is refreshed, ensuring role graph changes
208
         * applied outside the API before or during startup are not served stale.
209
         */
210
        @Override
211
        public void onApplicationEvent(ContextRefreshedEvent event) {
212
                clear();
1 ✔
213
        }
1 ✔
214

215
        /**
216
         * Flattens a role and its transitively inherited roles into an immutable {@link RolePrivileges},
217
         * with a visited set guarding against inheritance cycles. A <code>null</code> role grants nothing.
218
         * <p>
219
         * Resolves against the passed-in instance with <em>no freshness guarantee</em>, so it must not
220
         * drive a security decision on a possibly stale role.
221
         *
222
         * @param role the role to flatten
223
         * @return the flattened privilege closure
224
         */
225
        public static RolePrivileges computeRolePrivileges(Role role) {
226
                Set<String> privileges = new HashSet<>();
1 ✔
227
                Set<String> visited = new HashSet<>();
1 ✔
228
                boolean grantsSuperuser = collect(role, privileges, visited);
1 ✔
229
                return new RolePrivileges(privileges, grantsSuperuser);
1 ✔
230
        }
231

232
        /**
233
         * Depth-first walk over a role and its inherited roles, collecting normalized privilege names.
234
         *
235
         * @param role the role currently being visited
236
         * @param privileges accumulates normalized privilege names
237
         * @param visited role names already visited, to break inheritance cycles
238
         * @return true if this role or any role reachable from it confers superuser status
239
         */
240
        private static boolean collect(Role role, Set<String> privileges, Set<String> visited) {
241
                if (role == null || role.getRole() == null || !visited.add(RolePrivileges.normalize(role.getRole()))) {
1 ✔
242
                        return false;
1 ✔
243
                }
244

245
                // Superuser status can be inherited, so a superuser role anywhere in the closure grants it.
246
                boolean grantsSuperuser = RoleConstants.SUPERUSER.equalsIgnoreCase(role.getRole());
1 ✔
247

248
                if (role.getPrivileges() != null) {
1 ✔
249
                        for (Privilege privilege : role.getPrivileges()) {
1 ✔
250
                                if (privilege != null && privilege.getPrivilege() != null) {
1 ✔
251
                                        // RolePrivileges normalizes names on construction, so raw names are fine here.
252
                                        privileges.add(privilege.getPrivilege());
1 ✔
253
                                }
254
                        }
1 ✔
255
                }
256

257
                for (Role inherited : role.getInheritedRoles()) {
1 ✔
258
                        grantsSuperuser |= collect(inherited, privileges, visited);
1 ✔
259
                }
1 ✔
260

261
                return grantsSuperuser;
1 ✔
262
        }
263

264
        /**
265
         * Waits for the loads in progress to finish. A load writes or discards its cache entry before
266
         * handing over its result, so this is only needed to wait for loads the caller is not itself
267
         * waiting on, for example in tests.
268
         */
269
        void awaitLoads() {
270
                loads.forEach((key, load) -> {
1 ✔
271
                        try {
NEW
272
                                await(key, load);
×
NEW
273
                        } catch (APIException e) {
×
274
                                // the load's failure is its caller's to handle
NEW
275
                        }
×
NEW
276
                });
×
277
        }
1 ✔
278

279
        /**
280
         * Evicts every role immediately, even within a transaction, which still resolves roles without the
281
         * cache until it completes. Only for tests that load data behind the API.
282
         */
283
        void clearNow() {
284
                invalidation.clearNow();
1 ✔
285
        }
1 ✔
286

287
        /**
288
         * Lets the current transaction read through the cache again and not evict it when it completes.
289
         * Only for tests that load data behind the API but still need to observe cache hits.
290
         */
291
        void forgetWritesInCurrentTransaction() {
292
                invalidation.forgetWritesInCurrentTransaction();
1 ✔
293
        }
1 ✔
294

295
        /**
296
         * Returns the load in progress for the role if it started after the latest eviction, and otherwise
297
         * starts a new one. A load that started before the latest eviction may have read the role before
298
         * the change that caused it, so it is replaced rather than joined.
299
         */
300
        private Load startOrJoinLoad(String key, String roleName, Cache<Object, Object> cache) {
301
                Load fresh = new Load(cache == null ? null : CacheInvalidation.currentGeneration(cache));
1 ✔
302
                Load current = loads.compute(key,
1 ✔
303
                    (k, existing) -> existing != null && Objects.equals(existing.generation, fresh.generation) ? existing : fresh);
1 ✔
304
                if (current != fresh) {
1 ✔
305
                        return current;
1 ✔
306
                }
307

308
                Future<?> task = null;
1 ✔
309
                try {
310
                        task = backgroundRunner.apply(() -> {
1 ✔
311
                                try {
312
                                        fresh.result.complete(load(key, roleName, cache, fresh.generation));
1 ✔
313
                                } catch (Throwable e) {
1 ✔
314
                                        fresh.result.completeExceptionally(e);
1 ✔
315
                                } finally {
316
                                        loads.remove(key, fresh);
1 ✔
317
                                }
318
                        });
1 ✔
319
                } catch (RuntimeException e) {
1 ✔
320
                        fresh.result.completeExceptionally(e);
1 ✔
321
                        loads.remove(key, fresh);
1 ✔
322
                } finally {
323
                        fresh.task.complete(task);
1 ✔
324
                }
325
                return fresh;
1 ✔
326
        }
327

328
        /**
329
         * Runs on the background thread: loads a fresh copy of the role, flattens it, and caches it unless
330
         * the generation token has changed since <code>generation</code> was read. A role absent from the
331
         * database grants nothing.
332
         */
333
        private RolePrivileges load(String key, String roleName, Cache<Object, Object> cache, Object generation) {
334
                RolePrivileges loaded = computeRolePrivileges(committedRoleLoader.apply(roleName));
1 ✔
335
                if (cache != null) {
1 ✔
336
                        CacheInvalidation.putIfCurrent(cache, key, loaded, generation);
1 ✔
337
                }
338
                return loaded;
1 ✔
339
        }
340

341
        /**
342
         * Reads the role through the caller's own session and flattens it, without caching it. If the read
343
         * fails, the role grants nothing.
344
         *
345
         * @param loadFailure why the load failed, if it did, which is recorded on any failure of this read
346
         */
347
        private RolePrivileges resolveInCurrentSession(String roleName, Exception loadFailure) {
348
                try {
349
                        return computeRolePrivileges(dao.getRole(roleName));
1 ✔
350
                } catch (RuntimeException e) {
1 ✔
351
                        if (loadFailure != null) {
1 ✔
352
                                e.addSuppressed(loadFailure);
1 ✔
353
                        }
354
                        log.error("Could not load the privileges of role {}; it grants nothing", roleName, e);
1 ✔
355
                        return new RolePrivileges(new HashSet<>(), false);
1 ✔
356
                }
357
        }
358

359
        /**
360
         * Waits for the load. The thread's task is waited for first, since it can end without running the
361
         * load, which would then never complete.
362
         */
363
        private RolePrivileges await(String key, Load load) {
364
                try {
365
                        Future<?> task = load.task.join();
1 ✔
366
                        if (task != null) {
1 ✔
367
                                try {
368
                                        task.get();
1 ✔
369
                                } catch (ExecutionException e) {
1 ✔
370
                                        load.result.completeExceptionally(e.getCause());
1 ✔
371
                                        loads.remove(key, load);
1 ✔
372
                                }
1 ✔
373
                        }
374
                        return load.result.get();
1 ✔
375
                } catch (InterruptedException e) {
1 ✔
376
                        Thread.currentThread().interrupt();
1 ✔
377
                        throw new APIException("Interrupted while loading the privileges of role " + key, e);
1 ✔
378
                } catch (ExecutionException e) {
1 ✔
379
                        Throwable cause = e.getCause();
1 ✔
380
                        if (cause instanceof APIException) {
1 ✔
381
                                throw (APIException) cause;
1 ✔
382
                        }
383
                        if (cause instanceof Error) {
1 ✔
NEW
384
                                throw (Error) cause;
×
385
                        }
386
                        throw new APIException("Could not load the privileges of role " + key, cause);
1 ✔
387
                }
388
        }
389

390
        @SuppressWarnings("unchecked")
391
        private Cache<Object, Object> getCache() {
392
                org.springframework.cache.Cache cache = cacheManager.getCache(CACHE_NAME);
1 ✔
393
                return cache == null ? null : (Cache<Object, Object>) cache.getNativeCache();
1 ✔
394
        }
395

396
        /** A load in progress, with the generation token read before it started. */
397
        private static final class Load {
398

399
                private final Object generation;
400

401
                private final CompletableFuture<RolePrivileges> result = new CompletableFuture<>();
1 ✔
402

403
                /** The thread's task running the load, completed as soon as it has been started. */
404
                private final CompletableFuture<Future<?>> task = new CompletableFuture<>();
1 ✔
405

406
                private Load(Object generation) {
1 ✔
407
                        this.generation = generation;
1 ✔
408
                }
1 ✔
409
        }
410
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc