Require Get Alerts privilege to read all users' alerts#6186
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #6186 +/- ##
============================================
+ Coverage 59.32% 59.41% +0.08%
- Complexity 9332 9352 +20
============================================
Files 695 695
Lines 37451 37471 +20
Branches 5515 5521 +6
============================================
+ Hits 22219 22263 +44
+ Misses 13234 13213 -21
+ Partials 1998 1995 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
1a2cd2e to
c2d1d98
Compare
AlertService.getAllAlerts() and getAllAlerts(boolean) return every user's alerts, but were guarded only by @Authorized (authentication), so any authenticated user could read alerts addressed to others through any consumer (REST, legacy UI, modules, direct API). Introduce a dedicated Get Alerts privilege (following the GET_* read-privilege convention) and gate both methods with it. The per-user reads (getAlerts, getAlertsByUser, getAllActiveAlerts) stay open so a user can still read their own alerts. The privilege is created on startup via @AddOnStartup and is not auto-granted to any role, so this does not reintroduce the leak. AlertReminderTask reads all alerts as its configured (possibly unprivileged) scheduled-job user, so it grants itself a proxy Get Alerts privilege for that read. Upgrade note: existing roles that relied on Manage Alerts to list alerts must be granted the new Get Alerts privilege (super users are unaffected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c2d1d98 to
c6f4801
Compare
getAllAlerts() is gated by Get Alerts, but getAlert(Integer), getAlerts(User, ...), getAlertsByUser(User) and getAllActiveAlerts(User) still let any authenticated caller read another user's alerts by passing someone else's id/User. Guard them in the service layer: a caller may read their own alerts, and reading another user's alerts requires the Get Alerts privilege. getAlertsByUser and getAllActiveAlerts delegate through the guarded getAlerts; the self check compares userId, the same key the query filters on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkayiwa
left a comment
There was a problem hiding this comment.
Verdict: merge — clean, focused fix for the cross-user alert disclosure, with runtime tests that assert the actual authorization effect. No blocking issues. One non-blocking question inline.
What I verified:
AlertServiceTestis green on the PR head (mvn -pl api test -Dtest=AlertServiceTest→ 9/9), and the assertions are real behavior checks (a non-super user getsAPIAuthenticationExceptionreading another user's alerts, succeeds on their own and via a proxyGet Alerts) — they would fail onmaster, where the bare@AuthorizedongetAllAlertsonly required authentication.- The three delegating overloads (
getAllActiveAlerts,getAlertsByUser, andgetAllAlerts()) route throughContext.getAlertService(), so the in-method check ingetAlerts(User, …)and the@Authorized(GET_ALERTS)ongetAllAlerts(boolean)are enforced on those paths via the Spring proxy — not just on the directly-annotated methods. The tests confirm this for all three. - The only in-repo consumer of a gated method is
AlertReminderTask, which now brackets the read withaddProxyPrivilege/removeProxyPrivilegein afinally. The daemon path is also safe:Context.hasPrivilegeandAuthorizationAdviceboth short-circuit for daemon threads, so the proxy privilege is simply redundant there and load-bearing only on the JobRunr (non-daemon) path — matching the PR description. Reading one's own alerts (e.g.getAlertsByUser(null)) needs no new privilege, so normal users are unaffected.
Needs action before merge: none.
|
|
||
| // Alerts are personal notifications, so a caller may only read an alert addressed to them, | ||
| // unless they hold the Get Alerts privilege and may therefore read every user's alerts. | ||
| if (alert != null && !canViewAllAlerts() && alert.getRecipient(Context.getAuthenticatedUser()) == null) { |
There was a problem hiding this comment.
question: this gates content disclosure correctly, but it also turns getAlert into an existence oracle for IDs the caller doesn't own — it throws APIAuthenticationException when the alert exists and returns null when it doesn't, so an authenticated user can probe which (sequential) alert IDs exist. The getAlerts / getAlertsByUser / getAllActiveAlerts paths don't have this asymmetry because they gate on the target user identity before querying. Low sensitivity (only the existence of a notification leaks, not its content or recipient), so non-blocking — but since this PR is specifically about cross-user disclosure: is exposing existence intended, or would you rather return null for not-yours, the same as for not-found?
There was a problem hiding this comment.
Good catch — closed the oracle in 3636a8d.
getAlert now returns null when the alert is addressed to another user and the caller lacks Get Alerts, identical to the not-found path, so it can no longer be used to probe which (sequential) ids exist. Reading one's own alert, and privileged reads via Get Alerts, are unchanged.
I kept getAlerts / getAlertsByUser / getAllActiveAlerts throwing: there the caller supplies the target user identity, so the exception reveals nothing they didn't already assert — the asymmetry only mattered for the opaque-id lookup. I documented that contrast on getAlert's Javadoc so a caller used to the throwing overloads doesn't misread the null.
The test now asserts assertNull for both a not-yours id and a non-existent id, pinning the two as indistinguishable. mvn -pl api test -Dtest=AlertServiceTest → 9/9.
getAlert(Integer) threw APIAuthenticationException when an alert existed but was addressed to another user, yet returned null for a non-existent id - letting an authenticated caller distinguish the two and probe which alert ids exist. It now returns null in both cases so the lookup cannot be used as an existence oracle. Reading one's own alert, and privileged reads via the Get Alerts privilege, are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|



What
AlertService.getAllAlerts()andgetAllAlerts(boolean)return every user's alerts, but were guarded only by@Authorized(authentication) — so any authenticated user could read alerts addressed to others through any consumer (REST, legacy UI, modules, direct API).This introduces a dedicated
Get Alertsprivilege (following theGET_*read-privilege convention) and gates both methods with it. The per-user reads (getAlert,getAlerts,getAlertsByUser,getAllActiveAlerts) stay open for a caller reading their own alerts; reading another user's alerts through them now also requiresGet Alerts, closing the same cross-user disclosure at the by-id and per-user paths rather than only atgetAllAlerts.Why a dedicated privilege (not Manage Alerts)
Reads should use a
GET_*privilege rather than the write-orientedMANAGE_ALERTS, and a dedicated privilege allows granting read-all access without write. The privilege is registered via@AddOnStartupand created at startup bycheckCoreDataset(); it is not auto-assigned to any role, so it does not reintroduce the leak — only super users and explicitly-granted roles receive it.AlertReminderTask
The scheduled
AlertReminderTaskreads all alerts as its configured job user (the JobRunr path clears the daemon-thread flag before invoking the task, so@Authorizedis enforced). It now grants itself a proxyGet Alertsprivilege around that read.Upgrade note
A new
Get Alertsprivilege is created at startup (via@AddOnStartup/checkCoreDataset()) and is not assigned to any role. After upgrade, grantGet Alertsto any role that must read other users' alerts:Manage Alertsto list every user's alerts (e.g. the legacy "Manage Alerts" page, or any code callinggetAllAlerts()/getAllAlerts(boolean)).getAlert(Integer),getAlerts(User, …),getAlertsByUser(User)orgetAllActiveAlerts(User).Reading one's own alerts is unaffected and needs no new privilege. Super users are unaffected (they bypass privilege checks). Via REST,
GET /ws/rest/v1/alertnow returns only the caller's own alerts unless they holdGet Alerts(paired with openmrs/openmrs-module-webservices.rest#749).Tests
AlertServiceTest: callers withoutGet AlertsgetAPIAuthenticationExceptionwhen reading another user's alerts throughgetAllAlerts,getAlerts,getAlertsByUserandgetAllActiveAlerts, whilegetAlert(Integer)returnsnullfor another user's alert — the same as for a non-existent id, so it cannot be used as an existence oracle; reading their own succeeds; and an unprivileged caller holding a proxyGet Alertsprivilege succeeds (mirrors the task).mvn -pl api test -Dtest=AlertServiceTest→ 9/9.Notes
TRUNK-ticket.🤖 Generated with Claude Code