Skip to content

Require allergy privileges for getAllergies and setAllergies#6324

Merged
dkayiwa merged 3 commits into
openmrs:masterfrom
dkayiwa:security/patient-allergy-authorization
Jul 20, 2026
Merged

Require allergy privileges for getAllergies and setAllergies#6324
dkayiwa merged 3 commits into
openmrs:masterfrom
dkayiwa:security/patient-allergy-authorization

Conversation

@dkayiwa

@dkayiwa dkayiwa commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

PatientService.getAllergies(Patient) and setAllergies(Patient, Allergies) have no @Authorized annotation. Authorization is enforced in AuthorizationAdvice.before() only when a method declares at least one privilege; a method with none falls through with no check, not even authentication. So any authenticated caller who can reach these entry points can read another patient's allergy list and persist allergy changes for a patient, even though the dedicated per-item methods are guarded (getAllergy/getAllergyByUuid require Get Allergies; saveAllergy requires Add/Edit Allergies).

Change

  • getAllergies@Authorized({ GET_ALLERGIES })
  • setAllergies@Authorized({ ADD_ALLERGIES, EDIT_ALLERGIES })
  • Internal reads that would otherwise transitively demand Get Allergies from callers acquire it as a proxy privilege instead: the duplicate-allergen check in AllergyValidator, and the allergy reads mergePatients makes while reconciling the two patients' allergy lists.
  • Regression tests asserting each method now requires its privilege, and that a caller holding only an allergy write privilege can still save a new allergy.
  • The merge workflow tests now run mergePatients as a non-super user holding only the privileges the merge legitimately requires, with Get Allergies and Get Patient Cohorts deliberately absent, so the internal-read proxying is guarded by all 27 audit-based merge tests (suggested by @ibacher in review).
  • Running the merge as a non-super user surfaced one more leaked internal read: CohortServiceImpl.notifyPatientVoided reads cohort memberships through the service proxy, so voiding any patient demanded Get Patient Cohorts from the voiding user even though PatientDataVoidHandler already proxies Edit Cohorts for it (the annotation predates Require privileges for unprotected CohortService methods #6317). That read now acquires its own proxy privilege, matching the allergy reads.

Scope note

The advisory also raises that setAllergies does not bind each submitted Allergy to the outer patient argument (the DAO persists each allergy as-is). That is a separate data-integrity hardening that interacts with the void/recreate reconciliation logic in setAllergies, so I'm handling it in a follow-up with its own tests rather than bundling it here. This PR closes the missing-authorization half.

Addresses GHSA-jxvr-3qjq-vfqg.

PatientService.getAllergies(Patient) and setAllergies(Patient, Allergies)
carried no @Authorized annotation. AuthorizationAdvice only enforces when
a method declares a privilege, so both fell through with no check at all,
letting any authenticated caller read or modify any patient's allergy
list. The per-item siblings are already guarded (getAllergy and
getAllergyByUuid require Get Allergies; saveAllergy requires Add/Edit
Allergies). Annotate getAllergies with Get Allergies and setAllergies with
Add/Edit Allergies, and add regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.63%. Comparing base (d8fe136) to head (bf4ef22).
⚠️ Report is 11 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #6324      +/-   ##
============================================
+ Coverage     59.47%   59.63%   +0.15%     
- Complexity     9541     9574      +33     
============================================
  Files           731      731              
  Lines         38323    38409      +86     
  Branches       5587     5599      +12     
============================================
+ Hits          22794    22906     +112     
+ Misses        13489    13455      -34     
- Partials       2040     2048       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

PR openmrs#6324 added @Authorized(GET_ALLERGIES) to PatientService.getAllergies.
Two internal callers reach getAllergies through the AOP service proxy, so
they now transitively demand Get Allergies from the outer caller. This is a
behavior change the existing tests did not catch:

- AllergyValidator reads the patient's existing allergies to reject a
  duplicate allergen whenever a new allergy is saved (allergyId == null).
  That runs inside saveAllergy (RequiredDataAdvice -> ValidateUtil.validate
  -> AllergyValidator) and on the encounter-save path, so a user holding
  only Add/Edit Allergies could no longer add an allergy.
- PatientServiceImpl.mergeAllergies reads both patients' allergies, but
  mergePatients is only @Authorized(EDIT_PATIENTS), so merging began to
  require Get Allergies as well.

Wrap each internal read in the standard addProxyPrivilege /
removeProxyPrivilege idiom so the read is done on the caller's behalf
without forcing the outer operation to hold Get Allergies. The interface
@Authorized on getAllergies is left unchanged, so direct reads still
require the privilege.

Add a revert-sensitive PatientServiceTest that saves a new allergy as a
user holding only Add Allergies (not Get Allergies); without the fix it
fails with an APIAuthenticationException for Get Allergies raised from the
validator's duplicate check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TpKNfxm3QjwUBDePEtFm2H
Comment on lines +754 to +760
try {
Context.addProxyPrivilege(PrivilegeConstants.GET_ALLERGIES);
preferredAllergies = patientService.getAllergies(preferred);
notPreferredAllergies = patientService.getAllergies(notPreferred);
} finally {
Context.removeProxyPrivilege(PrivilegeConstants.GET_ALLERGIES);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proxy block is the one piece of the change that no test actually exercises. I confirmed it by reverting both wrappings on top of the new annotations: the saveAllergy duplicate-check test then fails with Privileges required: Get Allergies, but all four mergePatients allergy tests still pass, because they run as the super-user who already holds Get Allergies. If this block were dropped in a later refactor, CI would stay green while mergePatients started throwing APIAuthenticationException for any user who has Edit Patients but not Get Allergies.

The validator half of the same fix does have a guard test (the butch / Add-Allergies-only case), so this is the asymmetric spot. I would mirror that test here: become a user who holds the merge privileges but not Get Allergies, merge a patient who has an allergy, and assert it succeeds. The wrinkle is that mergePatients pulls in a fairly broad privilege set (relationships, obs, conditions, medication dispenses, patient programs, and so on), so the setup is bulkier than the saveAllergy test. If that is more scaffolding than it is worth, even a short comment here noting that the wrapping is only covered under super-user privileges would flag it for the next reader. Not blocking.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to ensure that the patient merge workflows are run with a non-super user user (which would patch the missing regression test here).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bf4ef22. mergeAndRetrieveAudit now runs the merge itself as butch (whose role grants no privileges) with proxy grants for only the privileges the merge legitimately requires, and Get Allergies deliberately left out. That closes the gap flagged above: with the proxy block reverted, all 27 audit-based merge workflow tests fail with Privileges required: Get Allergies, where previously all of them passed.

Running the merge without super-user privileges immediately paid off beyond allergies: it surfaced that voiding the losing patient demands Get Patient Cohorts from the merging user. PatientDataVoidHandler proxies Edit Cohorts around notifyPatientVoided, but the getCohortMemberships call inside that method goes through the service proxy, and its @Authorized annotation predates #6317, so any user voiding a patient without that privilege has been failing silently for a while. I gave that read the same proxy treatment as the allergy reads, and the test's privilege set omits Get Patient Cohorts too, so both internal reads are now guarded the same way (reverting the cohort proxy also fails all 27 tests, with Privileges required: Get Patient Cohorts).

The privileges that remain in the set are genuine demands of the workflow, including a few metadata reads (visit attribute types, the overlapping-visits global property, concepts, locations) that validators and change checks make through service proxies while the moved data is saved. Some of those could arguably get the internal-read treatment as well, but they are pre-existing and benign to hold, so the test documents them as-is rather than growing this PR further.

Comment on lines +754 to +760
try {
Context.addProxyPrivilege(PrivilegeConstants.GET_ALLERGIES);
preferredAllergies = patientService.getAllergies(preferred);
notPreferredAllergies = patientService.getAllergies(notPreferred);
} finally {
Context.removeProxyPrivilege(PrivilegeConstants.GET_ALLERGIES);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to ensure that the patient merge workflows are run with a non-super user user (which would patch the missing regression test here).

mergeAndRetrieveAudit now executes mergePatients as a user who holds
only the privileges the merge legitimately requires, with Get Allergies
deliberately absent, so every audit-based merge workflow test guards the
Get Allergies proxy block in mergeAllergies: removing that block fails
all 27 of them with "Privileges required: Get Allergies".

Running the merge without super-user privileges surfaced one more
internal read that leaked its privilege onto the caller: voiding the
losing patient triggers PatientDataVoidHandler, which proxies Edit
Cohorts for CohortService.notifyPatientVoided, but the membership read
inside that method goes through the service proxy and demanded Get
Patient Cohorts from the voiding user. That read now acquires its own
proxy privilege, matching the allergy reads, and the test privilege set
omits Get Patient Cohorts so both internal reads stay covered.

Suggested by @ibacher during review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@dkayiwa
dkayiwa merged commit f46e57d into openmrs:master Jul 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants