From bc48a195a698d66e21e396282847f29c83a39e11 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 2 Jul 2026 09:25:56 -0400 Subject: [PATCH 01/22] BB2-4694: Add support for the v3/fhir/AuditEvent endpoint --- apps/fhir/bluebutton/v3/urls.py | 11 +- apps/fhir/bluebutton/views/audit_event.py | 58 ++++++++ apps/fhir/constants.py | 2 +- requirements/requirements.dev.txt | 172 +++++++++++----------- requirements/requirements.txt | 6 +- 5 files changed, 157 insertions(+), 92 deletions(-) create mode 100644 apps/fhir/bluebutton/views/audit_event.py diff --git a/apps/fhir/bluebutton/v3/urls.py b/apps/fhir/bluebutton/v3/urls.py index 3a876ac99..f746a37b8 100644 --- a/apps/fhir/bluebutton/v3/urls.py +++ b/apps/fhir/bluebutton/v3/urls.py @@ -1,7 +1,9 @@ -from django.urls import re_path from django.contrib import admin +from django.urls import re_path from waffle.decorators import waffle_switch +from apps.fhir.bluebutton.views.audit_event import AuditEventView +from apps.fhir.bluebutton.views.insurancecard import DigitalInsuranceCardView from apps.fhir.bluebutton.views.read import ( ReadViewCoverage, ReadViewExplanationOfBenefit, @@ -12,7 +14,6 @@ SearchViewExplanationOfBenefit, SearchViewPatient, ) -from apps.fhir.bluebutton.views.insurancecard import DigitalInsuranceCardView admin.autodiscover() @@ -59,4 +60,10 @@ waffle_switch('v3_endpoints')(SearchViewExplanationOfBenefit.as_view(version=3)), name='bb_oauth_fhir_eob_search_v3', ), + # Audit Events View + re_path( + r'AuditEvent[/]?', + waffle_switch('v3_endpoints')(AuditEventView.as_view(version=3)), + name='bb_oauth_fhir_audit_event', + ), ] diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py new file mode 100644 index 000000000..6f033193e --- /dev/null +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -0,0 +1,58 @@ +from rest_framework import permissions + +from apps.authorization.permissions import DataAccessGrantPermission +from apps.fhir.bluebutton.permissions import ( + ApplicationActivePermission, + # AppScopePermission, + ResourcePermission, + SearchCrosswalkPermission, +) +from apps.fhir.bluebutton.views.generic import FhirDataView + +# from apps.fhir.constants import READ_SCOPE, READ_SEARCH_SCOPE_LOOKUP, SEARCH_SCOPE +from apps.versions import Versions + + +class HasAuditEventScope(permissions.BasePermission): + def has_permission(self, request, view) -> bool: # type: ignore + pass + + +class AuditEventView(FhirDataView): + """Audit Event view for handling BFD Endpoint""" + + permission_classes = [ + permissions.IsAuthenticated, + ApplicationActivePermission, + ResourcePermission, + SearchCrosswalkPermission, + DataAccessGrantPermission, + # AppScopePermission, #probably need to remove and replace with something else + ] + + def __init__(self, version=Versions.V3): + super().__init__(version) + self.resource_type = 'AuditEvent' + + def initial(self, request, *args, **kwargs): + return super().initial(request, self.resource_type, *args, **kwargs) + + def get(self, request, *args, **kwargs): + return super().get(request, self.resource_type, *args, **kwargs) + + def get_full_path(self): + return f'/{AuditEventView.version}/fhir/AuditEvent/' + + def build_parameters(self, request): + return { + '_format': 'application/fhir+json', + 'entity': request.crosswalk.fhir_id(self.version), + } + + def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwargs): + if self.version == Versions.V3 and getattr(fhir_settings, 'fhir_url_v3', None): + fhir_url = fhir_settings.fhir_url_v3 + else: + fhir_url = fhir_settings.fhir_url + + return f'{fhir_url}/v{self.version}/fhir/AuditEvent' diff --git a/apps/fhir/constants.py b/apps/fhir/constants.py index 1fa059f53..2a9720f7c 100644 --- a/apps/fhir/constants.py +++ b/apps/fhir/constants.py @@ -10,7 +10,7 @@ from apps.constants import FHIR_RES_TYPE_COVERAGE, FHIR_RES_TYPE_EOB, FHIR_RES_TYPE_PATIENT from apps.versions import Versions -ALLOWED_RESOURCE_TYPES = ['Patient', 'Coverage', 'ExplanationOfBenefit', 'Bundle'] +ALLOWED_RESOURCE_TYPES = ['Patient', 'Coverage', 'ExplanationOfBenefit', 'Bundle', 'AuditEvent'] MBI_URL = 'http://hl7.org/fhir/sid/us-mbi' MAX_RETRIES = 3 diff --git a/requirements/requirements.dev.txt b/requirements/requirements.dev.txt index 003290539..ac82426f8 100644 --- a/requirements/requirements.dev.txt +++ b/requirements/requirements.dev.txt @@ -512,9 +512,9 @@ djangorestframework-csv==3.0.0 \ --hash=sha256:46b39864f68cf42e7357a8be6109a85d484be52d6d8720bfac5e62163ec1a300 \ --hash=sha256:d6b2ee9a5fc62f98f6f4af241928836e2a2efab384d33def8ec722f9301602bf # via -r /code/requirements/requirements.in -fhir-core==1.1.8 \ - --hash=sha256:1a576ce3f6ec2aa3e86891dd00091ee03bcdcbb5a2f179f4f8e09b25b4d75047 \ - --hash=sha256:61b1fd984c1f3fc4ad2c4d96824d9ec6f92d19e9aa49269c89b9ebec878e778e +fhir-core==1.1.9 \ + --hash=sha256:61f23a711199d8d74390fd473cc5ddf75a1f4def97fd8e9f59a28bc9d5222261 \ + --hash=sha256:e3ee5912c1203993e915730e3eb23e40234f154822a45f15fadf51c76a17e452 # via fhir-resources fhir-resources==8.2.0 \ --hash=sha256:122a8b336385d504b399df9c5f332a7d913f07bc71a52dc9dde39abb5bf29129 \ @@ -672,86 +672,86 @@ geventhttpclient==2.3.9 \ --hash=sha256:f7cf60062d3aebd5e83f4d197a59609194effe25a25bcab01ae3775be18c877e \ --hash=sha256:f9d32fc9ba6d82c4f8586a6bd6e99c7e15a25404f94743dce00367aec826809d # via locust -greenlet==3.5.2 \ - --hash=sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421 \ - --hash=sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574 \ - --hash=sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad \ - --hash=sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34 \ - --hash=sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473 \ - --hash=sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32 \ - --hash=sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094 \ - --hash=sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4 \ - --hash=sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8 \ - --hash=sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea \ - --hash=sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb \ - --hash=sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8 \ - --hash=sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3 \ - --hash=sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682 \ - --hash=sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163 \ - --hash=sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c \ - --hash=sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c \ - --hash=sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f \ - --hash=sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c \ - --hash=sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742 \ - --hash=sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32 \ - --hash=sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de \ - --hash=sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904 \ - --hash=sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e \ - --hash=sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a \ - --hash=sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5 \ - --hash=sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f \ - --hash=sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef \ - --hash=sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16 \ - --hash=sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8 \ - --hash=sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b \ - --hash=sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2 \ - --hash=sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9 \ - --hash=sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3 \ - --hash=sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d \ - --hash=sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f \ - --hash=sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39 \ - --hash=sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6 \ - --hash=sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f \ - --hash=sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9 \ - --hash=sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce \ - --hash=sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00 \ - --hash=sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd \ - --hash=sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146 \ - --hash=sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3 \ - --hash=sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c \ - --hash=sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e \ - --hash=sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8 \ - --hash=sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f \ - --hash=sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb \ - --hash=sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5 \ - --hash=sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3 \ - --hash=sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317 \ - --hash=sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a \ - --hash=sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d \ - --hash=sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad \ - --hash=sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014 \ - --hash=sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0 \ - --hash=sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1 \ - --hash=sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e \ - --hash=sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5 \ - --hash=sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520 \ - --hash=sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7 \ - --hash=sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a \ - --hash=sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c \ - --hash=sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1 \ - --hash=sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73 \ - --hash=sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9 \ - --hash=sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5 \ - --hash=sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f \ - --hash=sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95 \ - --hash=sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e \ - --hash=sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c \ - --hash=sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6 \ - --hash=sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7 \ - --hash=sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728 \ - --hash=sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4 \ - --hash=sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74 \ - --hash=sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c +greenlet==3.5.3 \ + --hash=sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db \ + --hash=sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7 \ + --hash=sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8 \ + --hash=sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc \ + --hash=sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da \ + --hash=sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8 \ + --hash=sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91 \ + --hash=sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c \ + --hash=sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310 \ + --hash=sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3 \ + --hash=sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce \ + --hash=sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149 \ + --hash=sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d \ + --hash=sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34 \ + --hash=sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be \ + --hash=sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2 \ + --hash=sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b \ + --hash=sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0 \ + --hash=sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71 \ + --hash=sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d \ + --hash=sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04 \ + --hash=sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227 \ + --hash=sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c \ + --hash=sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6 \ + --hash=sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8 \ + --hash=sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21 \ + --hash=sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23 \ + --hash=sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605 \ + --hash=sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128 \ + --hash=sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f \ + --hash=sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea \ + --hash=sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81 \ + --hash=sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2 \ + --hash=sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8 \ + --hash=sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e \ + --hash=sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb \ + --hash=sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47 \ + --hash=sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8 \ + --hash=sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab \ + --hash=sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7 \ + --hash=sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861 \ + --hash=sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702 \ + --hash=sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814 \ + --hash=sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf \ + --hash=sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a \ + --hash=sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260 \ + --hash=sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1 \ + --hash=sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec \ + --hash=sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a \ + --hash=sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5 \ + --hash=sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1 \ + --hash=sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4 \ + --hash=sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c \ + --hash=sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda \ + --hash=sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb \ + --hash=sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31 \ + --hash=sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d \ + --hash=sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c \ + --hash=sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d \ + --hash=sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b \ + --hash=sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550 \ + --hash=sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c \ + --hash=sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357 \ + --hash=sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c \ + --hash=sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4 \ + --hash=sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930 \ + --hash=sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8 \ + --hash=sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b \ + --hash=sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb \ + --hash=sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16 \ + --hash=sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608 \ + --hash=sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f \ + --hash=sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc \ + --hash=sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d \ + --hash=sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44 \ + --hash=sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b \ + --hash=sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3 \ + --hash=sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154 \ + --hash=sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117 # via gevent gunicorn==25.0.3 \ --hash=sha256:aca364c096c81ca11acd4cede0aaeea91ba76ca74e2c0d7f879154db9d890f35 \ @@ -1471,9 +1471,9 @@ python-dotenv==1.2.2 \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 # via -r /code/requirements/requirements.in -python-engineio==4.13.2 \ - --hash=sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b \ - --hash=sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e +python-engineio==4.13.3 \ + --hash=sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f \ + --hash=sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9 # via # locust # python-socketio diff --git a/requirements/requirements.txt b/requirements/requirements.txt index dc44a6a5e..24a351a7f 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -347,9 +347,9 @@ djangorestframework-csv==3.0.0 \ --hash=sha256:46b39864f68cf42e7357a8be6109a85d484be52d6d8720bfac5e62163ec1a300 \ --hash=sha256:d6b2ee9a5fc62f98f6f4af241928836e2a2efab384d33def8ec722f9301602bf # via -r /code/requirements/requirements.in -fhir-core==1.1.8 \ - --hash=sha256:1a576ce3f6ec2aa3e86891dd00091ee03bcdcbb5a2f179f4f8e09b25b4d75047 \ - --hash=sha256:61b1fd984c1f3fc4ad2c4d96824d9ec6f92d19e9aa49269c89b9ebec878e778e +fhir-core==1.1.9 \ + --hash=sha256:61f23a711199d8d74390fd473cc5ddf75a1f4def97fd8e9f59a28bc9d5222261 \ + --hash=sha256:e3ee5912c1203993e915730e3eb23e40234f154822a45f15fadf51c76a17e452 # via fhir-resources fhir-resources==8.2.0 \ --hash=sha256:122a8b336385d504b399df9c5f332a7d913f07bc71a52dc9dde39abb5bf29129 \ From 660f5241f3e8b4ee9cdb9d906964c00d2e0ac172 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 2 Jul 2026 10:44:09 -0400 Subject: [PATCH 02/22] Add AuditEvent handling to is_resource_for_patient - avoid 404s --- apps/authorization/permissions.py | 13 ++++++++++--- apps/fhir/bluebutton/exceptions.py | 10 ++++++---- apps/fhir/bluebutton/views/audit_event.py | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/authorization/permissions.py b/apps/authorization/permissions.py index 89bd3f383..aa620d907 100644 --- a/apps/authorization/permissions.py +++ b/apps/authorization/permissions.py @@ -1,8 +1,8 @@ -from rest_framework import permissions, exceptions -from apps.constants import APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG -from apps.versions import Versions, VersionNotMatched +from rest_framework import exceptions, permissions from apps.authorization.models import DataAccessGrant +from apps.constants import APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG +from apps.versions import VersionNotMatched, Versions class DataAccessGrantPermission(permissions.BasePermission): @@ -59,6 +59,13 @@ def is_resource_for_patient(obj, patient_id): elif obj['resourceType'] == 'Bundle': for entry in obj.get('entry', []): is_resource_for_patient(entry['resource'], patient_id) + elif obj['resourceType'] == 'AuditEvent': + entity = obj.get('entity', [{}]) + patient_info = entity[0].get('what', {}) + reference = patient_info.get('reference', '') + reference_id = reference.split('/')[1] + if reference_id != patient_id: + raise exceptions.NotFound() else: raise exceptions.NotFound() diff --git a/apps/fhir/bluebutton/exceptions.py b/apps/fhir/bluebutton/exceptions.py index 44fc5304f..b5a626756 100644 --- a/apps/fhir/bluebutton/exceptions.py +++ b/apps/fhir/bluebutton/exceptions.py @@ -1,12 +1,14 @@ -from rest_framework.exceptions import NotFound, APIException -from rest_framework import status +from typing import Any, Dict, List + from requests import Response from requests.exceptions import JSONDecodeError -from typing import Any, Dict, List -from apps.versions import Versions +from rest_framework import status +from rest_framework.exceptions import APIException, NotFound + from apps.constants import OPERATION_OUTCOME from apps.fhir.bluebutton.models import Fhir_Response from apps.fhir.bluebutton.utils import is_operation_outcome +from apps.versions import Versions def process_error_response(response: Fhir_Response, version: int) -> APIException: diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index 6f033193e..5f5dc1c95 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -27,7 +27,7 @@ class AuditEventView(FhirDataView): ResourcePermission, SearchCrosswalkPermission, DataAccessGrantPermission, - # AppScopePermission, #probably need to remove and replace with something else + # AppScopePermission, # probably need to remove and replace with something else ] def __init__(self, version=Versions.V3): From e55a8fa53ebe8587628e69a09c8993b79f692f40 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 2 Jul 2026 15:34:52 -0400 Subject: [PATCH 03/22] 4695 piece of this, add protected capability and scopes for AuditEvent --- .../commands/create_blue_button_scopes.py | 19 +++++++++++++++++++ apps/constants.py | 1 + apps/dot_ext/views/authorization.py | 8 +++++++- apps/fhir/bluebutton/views/audit_event.py | 11 ++--------- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index 7feb3b58c..6250380e6 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -320,6 +320,24 @@ def create_token_introspect_capability(group): return c +def create_audit_event_capability(group): + c = None + description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + title = 'Audit Event FHIR Resource' + smart_scope_string = 'patient/AuditEvent.rs' + protected_resources = [] + protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/]?$']) + + if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists(): + c = ProtectedCapability.objects.create(group=group, + title=title, + description=description, + default=False, + slug=smart_scope_string, + protected_resources=json.dumps(protected_resources, indent=4)) + return c + + class Command(BaseCommand): help = 'Create BlueButton Group and Scopes' @@ -342,3 +360,4 @@ def handle(self, *args, **options): create_openid_capability(g) create_token_management_capability(g) create_token_introspect_capability(g) + create_audit_event_capability(g) diff --git a/apps/constants.py b/apps/constants.py index e79dbafcc..46f51da42 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -113,6 +113,7 @@ EOB_SCOPE = 'patient/ExplanationOfBenefit.rs' OPENID_SCOPE = 'openid' LAUNCH_SCOPE = 'launch/patient' +AUDIT_EVENT_SCOPE = 'patient/AuditEvent.rs' OPERATION_OUTCOME = 'OperationOutcome' diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 3f1d4612d..997d3429c 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -64,6 +64,7 @@ from apps.capabilities.models import ProtectedCapability from apps.constants import ( APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET, + AUDIT_EVENT_SCOPE, CLIENT_CREDENTIALS, CLIENT_CREDENTIALS_ACCEPTED_JWT_ALGORITHMS, HHS_SERVER_LOGNAME_FMT, @@ -1231,7 +1232,6 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: ) url, headers, body, status = self.create_token_response(request) - # retrieve the access token, update user_id with the user.id sourced above if status == HTTPStatus.OK: body = json.loads(body) @@ -1253,6 +1253,12 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: ) body['refresh_token'] = refresh_token.token + + # Even if the patient/AuditEvent.rs scope is not included in the request, add it to the token + if AUDIT_EVENT_SCOPE not in token.scope: + log.info('patient/AuditEvent.rs scope not requested for client_credentials call, adding it') + token.scope += ' ' + AUDIT_EVENT_SCOPE + token.user_id = user.id token.save() diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index 5f5dc1c95..6f99e36e4 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -1,23 +1,16 @@ from rest_framework import permissions from apps.authorization.permissions import DataAccessGrantPermission +from apps.capabilities.permissions import TokenHasProtectedCapability from apps.fhir.bluebutton.permissions import ( ApplicationActivePermission, - # AppScopePermission, ResourcePermission, SearchCrosswalkPermission, ) from apps.fhir.bluebutton.views.generic import FhirDataView - -# from apps.fhir.constants import READ_SCOPE, READ_SEARCH_SCOPE_LOOKUP, SEARCH_SCOPE from apps.versions import Versions -class HasAuditEventScope(permissions.BasePermission): - def has_permission(self, request, view) -> bool: # type: ignore - pass - - class AuditEventView(FhirDataView): """Audit Event view for handling BFD Endpoint""" @@ -27,7 +20,7 @@ class AuditEventView(FhirDataView): ResourcePermission, SearchCrosswalkPermission, DataAccessGrantPermission, - # AppScopePermission, # probably need to remove and replace with something else + TokenHasProtectedCapability, ] def __init__(self, version=Versions.V3): From b139522c85d72697ef819ab7b46ef10244820e18 Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 2 Jul 2026 16:19:39 -0400 Subject: [PATCH 04/22] Switch to default to True for new AuditEvent capability --- .../management/commands/create_blue_button_scopes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index 6250380e6..11cc20f51 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -332,7 +332,7 @@ def create_audit_event_capability(group): c = ProtectedCapability.objects.create(group=group, title=title, description=description, - default=False, + default=True, slug=smart_scope_string, protected_resources=json.dumps(protected_resources, indent=4)) return c From a764aa81001d266482ecf5c1dbefb39535f4c64d Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 6 Jul 2026 12:11:55 -0400 Subject: [PATCH 05/22] Modify unit tests to cover new functionality and add integration tests --- .../dot_ext/tests/test_authorization_token.py | 7 + apps/dot_ext/tests/test_views.py | 6 +- .../fhir/bluebutton/tests/test_audit_event.py | 65 +++++++++ apps/fhir/bluebutton/views/audit_event.py | 2 +- conftest.py | 138 ++++++++++++++++++ 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 apps/fhir/bluebutton/tests/test_audit_event.py diff --git a/apps/dot_ext/tests/test_authorization_token.py b/apps/dot_ext/tests/test_authorization_token.py index 080dfe8a1..b5dd397e5 100644 --- a/apps/dot_ext/tests/test_authorization_token.py +++ b/apps/dot_ext/tests/test_authorization_token.py @@ -18,6 +18,7 @@ from apps.capabilities.models import ProtectedCapability from apps.constants import ( + AUDIT_EVENT_SCOPE, CLIENT_CREDENTIALS, CODE_CHALLENGE_METHOD_S256, REFRESH_TOKEN, @@ -563,6 +564,12 @@ def test_client_credentials_token_and_refresh( self.assertIn('patient/ExplanationOfBenefit.rs', data['scope']) self.assertIn('refresh_token', data) + # BB2-4965: Even though patient/AuditEvent.rs was not in the requested scopes, it was automatically added + # to the token scope as all client_credentials auth flows should result in a token with patient/AuditEvent.rs + # on it + access_token = get_access_token_model().objects.get(token=data['access_token']) + assert AUDIT_EVENT_SCOPE in access_token.scope + refresh_request_data = { 'grant_type': REFRESH_TOKEN, 'refresh_token': data['refresh_token'], diff --git a/apps/dot_ext/tests/test_views.py b/apps/dot_ext/tests/test_views.py index 488971764..6c0af0ec3 100644 --- a/apps/dot_ext/tests/test_views.py +++ b/apps/dot_ext/tests/test_views.py @@ -18,7 +18,7 @@ from apps.authorization.models import ArchivedDataAccessGrant, DataAccessGrant from apps.capabilities.models import ProtectedCapability -from apps.constants import DEFAULT_SAMPLE_FHIR_ID_V2 +from apps.constants import AUDIT_EVENT_SCOPE, DEFAULT_SAMPLE_FHIR_ID_V2 from apps.dot_ext.constants import ( BENE_PERSONAL_INFO_SCOPES, SCOPES_TO_URL_BASE_PATH, @@ -58,6 +58,7 @@ def test_assigns_demographic_scopes(self): """ Assert that the registration view assigns demographic scopes when the user selects "yes" for "Does your application need to collect beneficiary demographic information" + Asserts that the patient/AuditEvent.rs scope is added by create_blue_button_scopes. """ call_command('create_blue_button_scopes') @@ -75,6 +76,9 @@ def test_assigns_demographic_scopes(self): default_scopes = ProtectedCapability.objects.filter(default=True) self.assertQuerySetEqual(app.scope.all(), default_scopes, ordered=False) + audit_event_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_SCOPE) + assert audit_event_scope is not None + def test_does_not_assign_demographic_scopes(self): """ Assert that the registration view does not assign demographic scopes when the diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py new file mode 100644 index 000000000..29357973f --- /dev/null +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -0,0 +1,65 @@ +from http import HTTPStatus + +import pytest +from django.test.client import Client +from django.urls import reverse +from waffle.testutils import override_switch + +from apps.integration_tests.constants import MESSAGE_NO_PERMISSION + +pytestmark = pytest.mark.django_db + + +@pytest.mark.integration +@override_switch('v3_endpoints', True) +@override_switch('require-scopes', True) +def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token): + access_token = get_access_token( + basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + ) + + response = Client().get( + reverse('bb_oauth_fhir_audit_event'), + kwargs={'entity': 'test'}, + Authorization='Bearer %s' % (access_token), + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.json()['detail'] == MESSAGE_NO_PERMISSION + + +@pytest.mark.integration +@override_switch('v3_endpoints', False) +@override_switch('require-scopes', True) +def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_token): + access_token = get_access_token( + basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + ) + + response = Client().get( + reverse('bb_oauth_fhir_audit_event'), + kwargs={'entity': 'test'}, + Authorization='Bearer %s' % (access_token), + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.integration +@override_switch('v3_endpoints', True) +@override_switch('require-scopes', True) +def test_successful_audit_event_call(basic_user, get_access_token, create_capability): + access_token = get_access_token( + basic_user.username, + 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs patient/AuditEvent.rs', + ) + create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/]?$']]) + + response = Client().get( + reverse('bb_oauth_fhir_audit_event'), + kwargs={'entity': 'test'}, + Authorization='Bearer %s' % (access_token), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()['resourceType'] == 'Bundle' diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index 6f99e36e4..33ca03ada 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -34,7 +34,7 @@ def get(self, request, *args, **kwargs): return super().get(request, self.resource_type, *args, **kwargs) def get_full_path(self): - return f'/{AuditEventView.version}/fhir/AuditEvent/' + return f'/v{AuditEventView.version}/fhir/AuditEvent/' def build_parameters(self, request): return { diff --git a/conftest.py b/conftest.py index cd402434d..3c7eeb2d6 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,20 @@ +import json +import secrets +from datetime import datetime, timedelta + +import pytest +from django.contrib.auth.models import Group, User +from oauth2_provider.models import get_access_token_model + +from apps.authorization.models import DataAccessGrant +from apps.capabilities.models import ProtectedCapability +from apps.constants import ( + DEFAULT_SAMPLE_FHIR_ID_V2, + DEFAULT_SAMPLE_FHIR_ID_V3, +) +from apps.dot_ext.models import Application +from apps.fhir.bluebutton.models import Crosswalk + # Ignore specific test files during collection collect_ignore = [ 'scripts/medicare-test-synth-logins/test_medicare_logins.py', @@ -6,3 +23,124 @@ 'apps/integration_tests/selenium_spanish_tests.py', 'apps/integration_tests/selenium_tests.py', ] + + +@pytest.fixture +def basic_user(db): + """A basic user with default test values.""" + + test_hicn_hash = '96228a57f37efea543f4f370f96f1dbf01c3e3129041dba3ea4367545507c6e7' + test_mbi = '1SA0A00AA00' + + if User.objects.filter(username='john').exists(): + return User.objects.get(username='john') + + user = User.objects.create_user( + username='john', + password='123456', + first_name='John', + last_name='Smith', + email='john.smith@test.notanagency.gov', + ) + + Crosswalk.objects.create( + user=user, + fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2, + fhir_id_v3=DEFAULT_SAMPLE_FHIR_ID_V3, + user_hicn_hash=test_hicn_hash, + user_mbi=test_mbi, + ) + + return user + + +@pytest.fixture +def get_access_token(db): + """ + Factory fixture that creates an AccessToken and DataAccessGrant + directly in the DB for a given username and application. + Usage: + def test_something(get_access_token): + token = get_access_token('john', application=my_app) + """ + + def _get_access_token( + username: str, + scope: str, + application=None, + **extra_fields, + ): + + AccessToken = get_access_token_model() + + user = User.objects.get(username=username) + + # Use provided application or get/create a default one + resolved_application = ( + application + or Application.objects.get_or_create( + name='test', + defaults={ + 'user': user, + 'client_type': Application.CLIENT_CONFIDENTIAL, + 'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE, + }, + )[0] + ) + + # Create the AccessToken object directly in the DB + access_token = AccessToken.objects.create( + user=user, + application=resolved_application, + token=secrets.token_hex(32), + expires=datetime.now() + timedelta(seconds=36000), + # scope='patient/Coverage.rs patient/Patient.rs patient/ExplanationOfBenefit.rs profile', + scope=scope, + **extra_fields, + ) + + DataAccessGrant.objects.update_or_create( + beneficiary=user, + application=resolved_application, + ) + + return access_token.token + + return _get_access_token + + +@pytest.fixture +def create_capability(db): + """ + Factory fixture that creates a ProtectedCapability instance + that controls access for a set of URLs. + Usage: + def test_something(create_capability): + capability = create_capability('My Capability', ['/api/v1/resource/']) + """ + + def _create_capability( + name: str, + urls: list, + group=None, + default: bool = True, + ): + # Return existing capability if it already exists + try: + return ProtectedCapability.objects.get(title=name) + except ProtectedCapability.DoesNotExist: + pass + + # Create a default group if none provided + resolved_group = group or Group.objects.get_or_create(name='test')[0] + + capability = ProtectedCapability.objects.create( + default=default, + title=name, + slug=name, + protected_resources=json.dumps(urls), + group=resolved_group, + ) + return capability + + return _create_capability From 3ede14e119321543ec91defc529dd2ac19e0bc49 Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 6 Jul 2026 15:50:06 -0400 Subject: [PATCH 06/22] Throw 400 on v2 auth request if AuditEvent scope included, add/modify tests --- apps/dot_ext/tests/test_authorization.py | 30 +++++++++++++++++++ apps/dot_ext/views/authorization.py | 11 ++++++- .../fhir/bluebutton/tests/test_audit_event.py | 27 ++++++++++++++--- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 094a239e8..b112a0036 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1885,3 +1885,33 @@ def test_fail_when_app_only_allowed_client_credentials(self): ), }, ) + + def test_failure_on_authorize_non_v3_with_audit_event_scope(self): + """Ensure a bad request 400 error, with message equal to Invalid scopes is raised + when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param + """ + redirect_uri = 'http://localhost' + # create a user + self._create_user('anna', '123456') + capability_a = self._create_capability('Capability A', []) + capability_b = self._create_capability('Capability B', []) + # create an application and add capabilities + application = self._create_application( + 'an app', + grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_type=Application.CLIENT_CONFIDENTIAL, + redirect_uris=redirect_uri, + ) + application.scope.add(capability_a, capability_b) + payload = { + 'client_id': application.client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': ['patient/Patient.rs patient/AuditEvent.rs'], + 'expires_in': 86400, + 'allow': True, + 'state': '0123456789abcdef', + } + response = self.client.post(reverse('oauth2_provider:authorize'), data=self._add_pkce_defaults(payload)) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json()['message'] == 'Invalid scopes.' diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 997d3429c..b8aed518c 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -297,7 +297,9 @@ def dispatch(self, request, *args, **kwargs): path_info = self.request.__dict__.get('path_info') version = get_api_version_number_from_url(path_info) - # If it is not version 3, we don't need to check anything, just continue + # Validate that the app is able to make v3 calls if it is a v3 call + # Otherwise, confirm patient/AuditEvent.rs is not in the scope parameter + # if it is a non-v3 call. if version == Versions.V3: try: self.validate_v3_authorization_request() @@ -309,6 +311,13 @@ def dispatch(self, request, *args, **kwargs): }, status=HTTPStatus.FORBIDDEN, ) + elif self.version != Versions.V3 and ( + AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') + ): + return JsonResponse( + {'status_code': HTTPStatus.BAD_REQUEST, 'message': 'Invalid scopes.'}, + status=HTTPStatus.BAD_REQUEST, + ) if self.application.allowed_auth_type == CLIENT_CREDENTIALS_TYPE: error_message = APPLICATION_HAS_CLIENT_CREDENTIALS_ENABLED_NON_CLIENT_CREDENTIALS_AUTH_CALL_MADE.format( diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 29357973f..0daea3a3a 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -7,7 +7,9 @@ from apps.integration_tests.constants import MESSAGE_NO_PERMISSION -pytestmark = pytest.mark.django_db +# pytestmark = pytest.mark.django_db + +client = Client() @pytest.mark.integration @@ -18,7 +20,7 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' ) - response = Client().get( + response = client.get( reverse('bb_oauth_fhir_audit_event'), kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), @@ -28,6 +30,23 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token assert response.json()['detail'] == MESSAGE_NO_PERMISSION +@pytest.mark.integration +@override_switch('v3_endpoints', True) +@override_switch('require-scopes', True) +def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): + access_token = get_access_token( + basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + ) + + response = client.get( + '/v2/fhir/AuditEvent', + kwargs={'entity': 'test'}, + Authorization='Bearer %s' % (access_token), + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.integration @override_switch('v3_endpoints', False) @override_switch('require-scopes', True) @@ -36,7 +55,7 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' ) - response = Client().get( + response = client.get( reverse('bb_oauth_fhir_audit_event'), kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), @@ -55,7 +74,7 @@ def test_successful_audit_event_call(basic_user, get_access_token, create_capabi ) create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/]?$']]) - response = Client().get( + response = client.get( reverse('bb_oauth_fhir_audit_event'), kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), From 424ba42a4257150b725cab6129eed1e7a82a004d Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 07:49:23 -0400 Subject: [PATCH 07/22] Modify tests and basic_user fixture --- .../audit_event_response.json | 98 +++++++++++++++++++ .../fhir/bluebutton/tests/test_audit_event.py | 56 +++++++++-- conftest.py | 80 ++++++++++----- 3 files changed, 200 insertions(+), 34 deletions(-) create mode 100644 apps/fhir/bluebutton/tests/sample_responses/audit_event_response.json diff --git a/apps/fhir/bluebutton/tests/sample_responses/audit_event_response.json b/apps/fhir/bluebutton/tests/sample_responses/audit_event_response.json new file mode 100644 index 000000000..e3739be2a --- /dev/null +++ b/apps/fhir/bluebutton/tests/sample_responses/audit_event_response.json @@ -0,0 +1,98 @@ +{ + "resourceType":"Bundle", + "id":"9287890d-04f0-4bcc-89f0-576eff993da9", + "meta":{ + "lastUpdated":"2026-07-06T19:59:39.261+00:00" + }, + "type":"searchset", + "total":1, + "link":[ + { + "relation":"self", + "url":"https://localhost:8000/v3/fhir/AuditEvent?_format=application%2Ffhir%2Bjson&entity=-502120048" + } + ], + "entry":[ + { + "resource":{ + "resourceType":"AuditEvent", + "id":"-502120048-20260706192218527441255", + "meta":{ + "profile":[ + "http://hl7.org/fhir/StructureDefinition/AuditEvent" + ] + }, + "type":{ + "system":"http://terminology.hl7.org/CodeSystem/iso-21089-lifecycle", + "code":"transmit" + }, + "subtype":[ + { + "system":"urn:cms:hte", + "code":"patient-match-request" + } + ], + "action":"E", + "recorded":"2026-07-06T19:22:18.527+00:00", + "outcome":"0", + "purposeOfEvent":[ + { + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code":"PATRQT", + "display":"patient requested" + } + ], + "text":"patient requested" + } + ], + "agent":[ + { + "altId":"TestApp", + "name":"TestApp", + "requestor":true, + "network":{ + "address":"192.168.127.1", + "type":"2" + } + } + ], + "source":{ + "observer":{ + "display":"CMS Beneficiary FHIR Data Server" + } + }, + "entity":[ + { + "what":{ + "reference":"Patient/-502120048" + }, + "type":{ + "system":"http://hl7.org/fhir/resource-types", + "code":"Patient", + "display":"Patient" + }, + "role":{ + "system":"http://terminology.hl7.org/CodeSystem/object-role", + "code":"1", + "display":"Patient" + }, + "name":"Match Info", + "description":"The detail elements refer to the matching algorithm + version combination that resulted in a unique match.", + "detail":[ + { + "type":"matchAlgorithm", + "valueString":"01" + }, + { + "type":"matchAlgorithmVersion", + "valueString":"3.2.2" + } + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 0daea3a3a..378ccb11c 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -1,23 +1,23 @@ +import json from http import HTTPStatus import pytest from django.test.client import Client from django.urls import reverse +from httmock import HTTMock, all_requests from waffle.testutils import override_switch from apps.integration_tests.constants import MESSAGE_NO_PERMISSION -# pytestmark = pytest.mark.django_db - client = Client() -@pytest.mark.integration @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token): + user = basic_user() access_token = get_access_token( - basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' ) response = client.get( @@ -30,12 +30,12 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token assert response.json()['detail'] == MESSAGE_NO_PERMISSION -@pytest.mark.integration @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): + user = basic_user() access_token = get_access_token( - basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' ) response = client.get( @@ -47,12 +47,12 @@ def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): assert response.status_code == HTTPStatus.NOT_FOUND -@pytest.mark.integration @override_switch('v3_endpoints', False) @override_switch('require-scopes', True) def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_token): + user = basic_user() access_token = get_access_token( - basic_user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' + user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' ) response = client.get( @@ -64,12 +64,50 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to assert response.status_code == HTTPStatus.NOT_FOUND +@override_switch('v3_endpoints', True) +@override_switch('require-scopes', True) +def test_mock_successful_audit_event_call(basic_user, get_access_token, create_capability): + user = basic_user( + username='damon', + first_name='Damon', + last_name='Mychart', + fhir_id_v2='custom_fhir_id', + fhir_id_v3='-502120048', + ) + access_token = get_access_token( + user.username, + 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs patient/AuditEvent.rs', + ) + create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/]?$']]) + + with open('apps/fhir/bluebutton/tests/sample_responses/audit_event_response.json') as f: + audit_event_bundle = json.load(f) + + @all_requests + def catchall(url, req): + return {'status_code': HTTPStatus.OK, 'content': audit_event_bundle} + + with HTTMock(catchall): + response = client.get( + reverse('bb_oauth_fhir_audit_event'), kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token) + ) + + json_response = response.json() + audit_event_resource = json_response.get('entry')[0].get('resource') + + assert response.status_code == HTTPStatus.OK + assert json_response.get('resourceType') == 'Bundle' + assert audit_event_resource.get('resourceType') == 'AuditEvent' + assert audit_event_resource.get('id') == '-502120048-20260706192218527441255' + + @pytest.mark.integration @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_successful_audit_event_call(basic_user, get_access_token, create_capability): + user = basic_user() access_token = get_access_token( - basic_user.username, + user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs patient/AuditEvent.rs', ) create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/]?$']]) diff --git a/conftest.py b/conftest.py index 3c7eeb2d6..9ae71d449 100644 --- a/conftest.py +++ b/conftest.py @@ -27,31 +27,61 @@ @pytest.fixture def basic_user(db): - """A basic user with default test values.""" - - test_hicn_hash = '96228a57f37efea543f4f370f96f1dbf01c3e3129041dba3ea4367545507c6e7' - test_mbi = '1SA0A00AA00' - - if User.objects.filter(username='john').exists(): - return User.objects.get(username='john') - - user = User.objects.create_user( - username='john', - password='123456', - first_name='John', - last_name='Smith', - email='john.smith@test.notanagency.gov', - ) - - Crosswalk.objects.create( - user=user, - fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2, - fhir_id_v3=DEFAULT_SAMPLE_FHIR_ID_V3, - user_hicn_hash=test_hicn_hash, - user_mbi=test_mbi, - ) - - return user + """ + Factory fixture that creates a User with associated Crosswalk data. + Falls back to default test values if no arguments are provided. + + Usage: + # Use all defaults + def test_something(basic_user): + user = basic_user() + + # Override specific values + def test_something(basic_user): + user = basic_user( + username='jane', + first_name='Jane', + last_name='Doe', + fhir_id_v2='custom_fhir_id', + ) + """ + + def _basic_user( + username: str = 'john', + password: str = '123456', + first_name: str = 'John', + last_name: str = 'Smith', + email: str = None, + fhir_id_v2: str = DEFAULT_SAMPLE_FHIR_ID_V2, + fhir_id_v3: str = DEFAULT_SAMPLE_FHIR_ID_V3, + hicn_hash: str = '96228a57f37efea543f4f370f96f1dbf01c3e3129041dba3ea4367545507c6e7', + mbi: str = '1SA0A00AA00', + ): + resolved_email = email or f'{first_name.lower()}.{last_name.lower()}@test.notanagency.gov' + + # Return existing user if already created + if User.objects.filter(username=username).exists(): + return User.objects.get(username=username) + + user = User.objects.create_user( + username=username, + password=password, + first_name=first_name, + last_name=last_name, + email=resolved_email, + ) + + Crosswalk.objects.create( + user=user, + fhir_id_v2=fhir_id_v2, + fhir_id_v3=fhir_id_v3, + user_hicn_hash=hicn_hash, + user_mbi=mbi, + ) + + return user + + return _basic_user @pytest.fixture From e1f4b83b66856dd4972f2fb937151194ce90ab10 Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 08:10:40 -0400 Subject: [PATCH 08/22] Add an application fixture, modify tests, add docstrings --- apps/dot_ext/tests/test_authorization.py | 53 +++++++++---------- .../fhir/bluebutton/tests/test_audit_event.py | 33 ++++++++++++ conftest.py | 50 +++++++++++++++++ 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index b112a0036..6317a1d58 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1886,32 +1886,27 @@ def test_fail_when_app_only_allowed_client_credentials(self): }, ) - def test_failure_on_authorize_non_v3_with_audit_event_scope(self): - """Ensure a bad request 400 error, with message equal to Invalid scopes is raised - when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param - """ - redirect_uri = 'http://localhost' - # create a user - self._create_user('anna', '123456') - capability_a = self._create_capability('Capability A', []) - capability_b = self._create_capability('Capability B', []) - # create an application and add capabilities - application = self._create_application( - 'an app', - grant_type=Application.GRANT_AUTHORIZATION_CODE, - client_type=Application.CLIENT_CONFIDENTIAL, - redirect_uris=redirect_uri, - ) - application.scope.add(capability_a, capability_b) - payload = { - 'client_id': application.client_id, - 'response_type': 'code', - 'redirect_uri': redirect_uri, - 'scope': ['patient/Patient.rs patient/AuditEvent.rs'], - 'expires_in': 86400, - 'allow': True, - 'state': '0123456789abcdef', - } - response = self.client.post(reverse('oauth2_provider:authorize'), data=self._add_pkce_defaults(payload)) - assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.json()['message'] == 'Invalid scopes.' + +def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application): + """Ensure a bad request 400 error, with message equal to Invalid scopes is raised + when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param + """ + redirect_uri = 'http://localhost' + + # create an application via fixture + application = create_application('an app') + payload = { + 'client_id': application.client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': ['patient/Patient.rs patient/AuditEvent.rs'], + 'expires_in': 86400, + 'allow': True, + 'state': '0123456789abcdef', + 'code_challenge': 'sZrievZsrYqxdnu2NVD603EiYBM18CuzZpwB-pOSZjo', + 'code_challenge_method': CODE_CHALLENGE_METHOD_S256, + } + response = Client().post(reverse('oauth2_provider:authorize'), data=payload) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json()['message'] == 'Invalid scopes.' diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 378ccb11c..4cb98a8a9 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -15,6 +15,12 @@ @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token): + """Try to make an audit_event call without having the appropriate scope, confirm a 403 is returned + + Args: + basic_user (_type_): Fixture for a basic_user + get_access_token (_type_): Fixture to create an access token + """ user = basic_user() access_token = get_access_token( user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' @@ -33,6 +39,12 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): + """Try to make a v2 audit event call, which BlueButton does not support. Confirm a 404 is returned + + Args: + basic_user (_type_): Fixture for a basic_user + get_access_token (_type_): Fixture to create an access token + """ user = basic_user() access_token = get_access_token( user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' @@ -50,6 +62,12 @@ def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): @override_switch('v3_endpoints', False) @override_switch('require-scopes', True) def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_token): + """Try to make a v3 audit event call, with the v3_endpoints flag set to False. Confirm a 404 is returned. + + Args: + basic_user (_type_): Fixture for a basic_user + get_access_token (_type_): Fixture to create an access token + """ user = basic_user() access_token = get_access_token( user.username, 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs profile' @@ -67,6 +85,14 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_mock_successful_audit_event_call(basic_user, get_access_token, create_capability): + """With a mock AuditEvent response, confirm we get the expected resources back and are able + to parse the response as expected + + Args: + basic_user (_type_): Fixture for a basic_user + get_access_token (_type_): Fixture to create an access token + create_capability (_type_): Fixture to create a capabilities_protectedcapability record + """ user = basic_user( username='damon', first_name='Damon', @@ -105,6 +131,13 @@ def catchall(url, req): @override_switch('v3_endpoints', True) @override_switch('require-scopes', True) def test_successful_audit_event_call(basic_user, get_access_token, create_capability): + """Make an actual live call to BFD for the AuditEvent endpoint that returns a 200 + + Args: + basic_user (_type_): Fixture for a basic_user + get_access_token (_type_): Fixture to create an access token + create_capability (_type_): Fixture to create a capabilities_protectedcapability record + """ user = basic_user() access_token = get_access_token( user.username, diff --git a/conftest.py b/conftest.py index 9ae71d449..318848678 100644 --- a/conftest.py +++ b/conftest.py @@ -174,3 +174,53 @@ def _create_capability( return capability return _create_capability + + +@pytest.fixture +def create_application(db): + """ + Factory fixture that creates an Application instance with a name, + client_type, grant_type, and optional capability. + The default client_type is APPLICATION.CLIENT_PUBLIC. + The default grant_type is APPLICATION.GRANT_PASSWORD. + Usage: + def test_something(create_application): + app = create_application('my_app') + """ + + def _create_application( + name: str, + client_type=None, + grant_type=None, + capability=None, + user=None, + data_access_type=None, + **kwargs, + ): + + client_type = client_type or Application.CLIENT_PUBLIC + grant_type = grant_type or Application.GRANT_PASSWORD + + # Use provided user or get/create a default dev user + dev_user = user or User.objects.get_or_create(username='dev', defaults={'password': '123456'})[0] + + application = Application.objects.create( + name=name, + user=dev_user, + client_type=client_type, + authorization_grant_type=grant_type, + **kwargs, + ) + + # Set data access type if provided + if data_access_type: + application.data_access_type = data_access_type + application.save() + + # Add capability if provided + if capability: + application.scope.add(capability) + + return application + + return _create_application From 42ded52213c8700f36cfecdb87eafee9edf590a4 Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 09:38:13 -0400 Subject: [PATCH 09/22] Requirement updates --- requirements/requirements.dev.txt | 6 +++--- requirements/requirements.txt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements.dev.txt b/requirements/requirements.dev.txt index 590e5df89..daab8eae6 100644 --- a/requirements/requirements.dev.txt +++ b/requirements/requirements.dev.txt @@ -575,9 +575,9 @@ envier==0.6.1 \ --hash=sha256:3309a01bb3d8850c9e7a31a5166d5a836846db2faecb79b9cb32654dd50ca9f9 \ --hash=sha256:73609040a76be48bbcb97074d9969666484aa0de706183a6e9ef773156a8a6a9 # via ddtrace -fhir-core==1.1.8 \ - --hash=sha256:1a576ce3f6ec2aa3e86891dd00091ee03bcdcbb5a2f179f4f8e09b25b4d75047 \ - --hash=sha256:61b1fd984c1f3fc4ad2c4d96824d9ec6f92d19e9aa49269c89b9ebec878e778e +fhir-core==1.1.9 \ + --hash=sha256:61f23a711199d8d74390fd473cc5ddf75a1f4def97fd8e9f59a28bc9d5222261 \ + --hash=sha256:e3ee5912c1203993e915730e3eb23e40234f154822a45f15fadf51c76a17e452 # via fhir-resources fhir-resources==8.2.0 \ --hash=sha256:122a8b336385d504b399df9c5f332a7d913f07bc71a52dc9dde39abb5bf29129 \ diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d1d5db2c5..fe760b798 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -410,9 +410,9 @@ envier==0.6.1 \ --hash=sha256:3309a01bb3d8850c9e7a31a5166d5a836846db2faecb79b9cb32654dd50ca9f9 \ --hash=sha256:73609040a76be48bbcb97074d9969666484aa0de706183a6e9ef773156a8a6a9 # via ddtrace -fhir-core==1.1.8 \ - --hash=sha256:1a576ce3f6ec2aa3e86891dd00091ee03bcdcbb5a2f179f4f8e09b25b4d75047 \ - --hash=sha256:61b1fd984c1f3fc4ad2c4d96824d9ec6f92d19e9aa49269c89b9ebec878e778e +fhir-core==1.1.9 \ + --hash=sha256:61f23a711199d8d74390fd473cc5ddf75a1f4def97fd8e9f59a28bc9d5222261 \ + --hash=sha256:e3ee5912c1203993e915730e3eb23e40234f154822a45f15fadf51c76a17e452 # via fhir-resources fhir-resources==8.2.0 \ --hash=sha256:122a8b336385d504b399df9c5f332a7d913f07bc71a52dc9dde39abb5bf29129 \ From 069010956e4076e0e89bbe23670234f00dff2332 Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 09:44:00 -0400 Subject: [PATCH 10/22] Clean up doc strings --- .../fhir/bluebutton/tests/test_audit_event.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 4cb98a8a9..f8125b3b3 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -18,8 +18,8 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token """Try to make an audit_event call without having the appropriate scope, confirm a 403 is returned Args: - basic_user (_type_): Fixture for a basic_user - get_access_token (_type_): Fixture to create an access token + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token """ user = basic_user() access_token = get_access_token( @@ -42,8 +42,8 @@ def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): """Try to make a v2 audit event call, which BlueButton does not support. Confirm a 404 is returned Args: - basic_user (_type_): Fixture for a basic_user - get_access_token (_type_): Fixture to create an access token + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token """ user = basic_user() access_token = get_access_token( @@ -65,8 +65,8 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to """Try to make a v3 audit event call, with the v3_endpoints flag set to False. Confirm a 404 is returned. Args: - basic_user (_type_): Fixture for a basic_user - get_access_token (_type_): Fixture to create an access token + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token """ user = basic_user() access_token = get_access_token( @@ -89,9 +89,9 @@ def test_mock_successful_audit_event_call(basic_user, get_access_token, create_c to parse the response as expected Args: - basic_user (_type_): Fixture for a basic_user - get_access_token (_type_): Fixture to create an access token - create_capability (_type_): Fixture to create a capabilities_protectedcapability record + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token + create_capability: Fixture to create a capabilities_protectedcapability record """ user = basic_user( username='damon', @@ -134,9 +134,9 @@ def test_successful_audit_event_call(basic_user, get_access_token, create_capabi """Make an actual live call to BFD for the AuditEvent endpoint that returns a 200 Args: - basic_user (_type_): Fixture for a basic_user - get_access_token (_type_): Fixture to create an access token - create_capability (_type_): Fixture to create a capabilities_protectedcapability record + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token + create_capability: Fixture to create a capabilities_protectedcapability record """ user = basic_user() access_token = get_access_token( From 8a993a42cc1c9ab1ace6fd2080d352912bccdf11 Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 10:11:15 -0400 Subject: [PATCH 11/22] Changes per Copilot PR feedback --- apps/dot_ext/tests/test_views.py | 2 +- apps/fhir/bluebutton/tests/test_audit_event.py | 8 +------- apps/fhir/bluebutton/views/audit_event.py | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/dot_ext/tests/test_views.py b/apps/dot_ext/tests/test_views.py index 6c0af0ec3..dec0db138 100644 --- a/apps/dot_ext/tests/test_views.py +++ b/apps/dot_ext/tests/test_views.py @@ -77,7 +77,7 @@ def test_assigns_demographic_scopes(self): self.assertQuerySetEqual(app.scope.all(), default_scopes, ordered=False) audit_event_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_SCOPE) - assert audit_event_scope is not None + assert audit_event_scope.exists() def test_does_not_assign_demographic_scopes(self): """ diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index f8125b3b3..014c6a5c9 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -28,7 +28,6 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token response = client.get( reverse('bb_oauth_fhir_audit_event'), - kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), ) @@ -52,7 +51,6 @@ def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): response = client.get( '/v2/fhir/AuditEvent', - kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), ) @@ -75,7 +73,6 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to response = client.get( reverse('bb_oauth_fhir_audit_event'), - kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), ) @@ -114,9 +111,7 @@ def catchall(url, req): return {'status_code': HTTPStatus.OK, 'content': audit_event_bundle} with HTTMock(catchall): - response = client.get( - reverse('bb_oauth_fhir_audit_event'), kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token) - ) + response = client.get(reverse('bb_oauth_fhir_audit_event'), Authorization='Bearer %s' % (access_token)) json_response = response.json() audit_event_resource = json_response.get('entry')[0].get('resource') @@ -147,7 +142,6 @@ def test_successful_audit_event_call(basic_user, get_access_token, create_capabi response = client.get( reverse('bb_oauth_fhir_audit_event'), - kwargs={'entity': 'test'}, Authorization='Bearer %s' % (access_token), ) diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index 33ca03ada..59b34ee35 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -34,7 +34,7 @@ def get(self, request, *args, **kwargs): return super().get(request, self.resource_type, *args, **kwargs) def get_full_path(self): - return f'/v{AuditEventView.version}/fhir/AuditEvent/' + return f'/v{self.version}/fhir/AuditEvent/' def build_parameters(self, request): return { From bb5ddf74a47d5e7bbd9d905909a96aba7998ea3d Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 7 Jul 2026 14:32:25 -0400 Subject: [PATCH 12/22] Incorporate changes from 4963 being merged --- .../commands/create_blue_button_scopes.py | 5 +++- apps/dot_ext/tests/test_authorization.py | 1 + .../dot_ext/tests/test_authorization_token.py | 1 + apps/dot_ext/tests/test_views.py | 1 + apps/dot_ext/views/authorization.py | 10 +++++--- .../fhir/bluebutton/tests/test_audit_event.py | 23 +++++++++++-------- apps/fhir/bluebutton/v3/urls.py | 2 +- 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index 11cc20f51..b84585958 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -4,6 +4,7 @@ from django.contrib.auth.models import Group from django.urls import reverse from django.core.management.base import BaseCommand +from waffle import switch_is_active from apps.capabilities.constants import FHIR_PREFIX_CREATE_BLUE_BUTTON_SCOPES from apps.capabilities.models import ProtectedCapability @@ -360,4 +361,6 @@ def handle(self, *args, **options): create_openid_capability(g) create_token_management_capability(g) create_token_introspect_capability(g) - create_audit_event_capability(g) + + if switch_is_active('enable_auditevents'): + create_audit_event_capability(g) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 6317a1d58..dec2a402c 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1887,6 +1887,7 @@ def test_fail_when_app_only_allowed_client_credentials(self): ) +@override_switch('enable_auditevents', True) def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application): """Ensure a bad request 400 error, with message equal to Invalid scopes is raised when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param diff --git a/apps/dot_ext/tests/test_authorization_token.py b/apps/dot_ext/tests/test_authorization_token.py index b5dd397e5..7567d4c74 100644 --- a/apps/dot_ext/tests/test_authorization_token.py +++ b/apps/dot_ext/tests/test_authorization_token.py @@ -478,6 +478,7 @@ def setUp(self): @patch('apps.dot_ext.views.authorization.TokenView._create_or_retrieve_user') @patch('apps.dot_ext.views.authorization.get_patient_match_response_json') @override_switch('v3_endpoints', active=True) + @override_switch('enable_auditevents', active=True) def test_client_credentials_token_and_refresh( self, mock_get_patient, mock_create_user, mock_validate_ial, mock_validate_auth, mock_get_and_update ): diff --git a/apps/dot_ext/tests/test_views.py b/apps/dot_ext/tests/test_views.py index dec0db138..6050cd644 100644 --- a/apps/dot_ext/tests/test_views.py +++ b/apps/dot_ext/tests/test_views.py @@ -54,6 +54,7 @@ def test_assigns_default_scopes(self): self.assertQuerySetEqual(app.scope.all(), [capability_a, capability_b], ordered=False) + @override_switch('enable_auditevents', True) def test_assigns_demographic_scopes(self): """ Assert that the registration view assigns demographic scopes when the user diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index b8aed518c..fe5a1cc49 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -311,8 +311,12 @@ def dispatch(self, request, *args, **kwargs): }, status=HTTPStatus.FORBIDDEN, ) - elif self.version != Versions.V3 and ( - AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') + elif ( + switch_is_active('enable_auditevents') + and self.version != Versions.V3 + and ( + AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') + ) ): return JsonResponse( {'status_code': HTTPStatus.BAD_REQUEST, 'message': 'Invalid scopes.'}, @@ -1264,7 +1268,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: body['refresh_token'] = refresh_token.token # Even if the patient/AuditEvent.rs scope is not included in the request, add it to the token - if AUDIT_EVENT_SCOPE not in token.scope: + if switch_is_active('enable_auditevents') and AUDIT_EVENT_SCOPE not in token.scope: log.info('patient/AuditEvent.rs scope not requested for client_credentials call, adding it') token.scope += ' ' + AUDIT_EVENT_SCOPE diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 014c6a5c9..efc32f949 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -12,8 +12,9 @@ client = Client() -@override_switch('v3_endpoints', True) -@override_switch('require-scopes', True) +@override_switch('v3_endpoints', active=True) +@override_switch('require-scopes', active=True) +@override_switch('enable_auditevents', active=True) def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token): """Try to make an audit_event call without having the appropriate scope, confirm a 403 is returned @@ -35,8 +36,8 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token assert response.json()['detail'] == MESSAGE_NO_PERMISSION -@override_switch('v3_endpoints', True) -@override_switch('require-scopes', True) +@override_switch('v3_endpoints', active=True) +@override_switch('require-scopes', active=True) def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): """Try to make a v2 audit event call, which BlueButton does not support. Confirm a 404 is returned @@ -57,8 +58,8 @@ def test_v2_audit_event_call_confirm_not_found(basic_user, get_access_token): assert response.status_code == HTTPStatus.NOT_FOUND -@override_switch('v3_endpoints', False) -@override_switch('require-scopes', True) +@override_switch('v3_endpoints', active=False) +@override_switch('require-scopes', active=True) def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_token): """Try to make a v3 audit event call, with the v3_endpoints flag set to False. Confirm a 404 is returned. @@ -79,8 +80,9 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to assert response.status_code == HTTPStatus.NOT_FOUND -@override_switch('v3_endpoints', True) -@override_switch('require-scopes', True) +@override_switch('v3_endpoints', active=True) +@override_switch('require-scopes', active=True) +@override_switch('enable_auditevents', active=True) def test_mock_successful_audit_event_call(basic_user, get_access_token, create_capability): """With a mock AuditEvent response, confirm we get the expected resources back and are able to parse the response as expected @@ -123,8 +125,9 @@ def catchall(url, req): @pytest.mark.integration -@override_switch('v3_endpoints', True) -@override_switch('require-scopes', True) +@override_switch('v3_endpoints', active=True) +@override_switch('require-scopes', active=True) +@override_switch('enable_auditevents', active=True) def test_successful_audit_event_call(basic_user, get_access_token, create_capability): """Make an actual live call to BFD for the AuditEvent endpoint that returns a 200 diff --git a/apps/fhir/bluebutton/v3/urls.py b/apps/fhir/bluebutton/v3/urls.py index f746a37b8..332fff94d 100644 --- a/apps/fhir/bluebutton/v3/urls.py +++ b/apps/fhir/bluebutton/v3/urls.py @@ -63,7 +63,7 @@ # Audit Events View re_path( r'AuditEvent[/]?', - waffle_switch('v3_endpoints')(AuditEventView.as_view(version=3)), + waffle_switch('enable_auditevents')(waffle_switch('v3_endpoints')(AuditEventView.as_view(version=3))), name='bb_oauth_fhir_audit_event', ), ] From acea576a34b6d96583a35c24e0957040d8a869cf Mon Sep 17 00:00:00 2001 From: James Demery Date: Thu, 9 Jul 2026 13:18:51 -0400 Subject: [PATCH 13/22] Address initial feedback: Ensure AuditEvent is returned, fail v3 auth if AuditEvent in scope --- apps/dot_ext/constants.py | 7 ++++++- apps/dot_ext/tests/test_authorization.py | 3 ++- apps/dot_ext/views/authorization.py | 13 ++++++------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/dot_ext/constants.py b/apps/dot_ext/constants.py index 2610009af..b0ccaa34c 100644 --- a/apps/dot_ext/constants.py +++ b/apps/dot_ext/constants.py @@ -1,6 +1,6 @@ import re -from apps.constants import LAUNCH_SCOPE, OPENID_SCOPE +from apps.constants import AUDIT_EVENT_SCOPE, LAUNCH_SCOPE, OPENID_SCOPE # REGEX of paths that should be updated with auth flow info in hhs_oauth_server.request_logging.py AUTH_FLOW_REQUEST_LOGGING_PATHS_REGEX = ( @@ -1648,3 +1648,8 @@ ID_ME_URL_CONTAINS = 'oidc' SECONDS_IN_ONE_HOUR = 3600 + +AUDIT_EVENT_SCOPE_ERROR_MESSAGE = ( + AUDIT_EVENT_SCOPE + + ' is only valid in token requests through CMS Aligned Networks. For more information, go to this page: https://bluebutton.cms.gov/cms-aligned-networks-documentation/' +) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index dec2a402c..5c1a3ee2a 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -22,6 +22,7 @@ from apps.constants import CODE_CHALLENGE_METHOD_S256, PATIENT_SCOPE from apps.dot_ext.constants import ( APPLICATION_HAS_CLIENT_CREDENTIALS_ENABLED_NON_CLIENT_CREDENTIALS_AUTH_CALL_MADE, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, CLIENT_CREDENTIALS_TYPE, ) from apps.dot_ext.models import Application, ArchivedToken @@ -1910,4 +1911,4 @@ def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application): response = Client().post(reverse('oauth2_provider:authorize'), data=payload) assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.json()['message'] == 'Invalid scopes.' + assert response.json()['message'] == AUDIT_EVENT_SCOPE_ERROR_MESSAGE diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index fe5a1cc49..9c9679395 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -75,6 +75,7 @@ from apps.dot_ext.constants import ( APPLICATION_DOES_NOT_HAVE_CLIENT_CREDENTIALS_ENABLED, APPLICATION_HAS_CLIENT_CREDENTIALS_ENABLED_NON_CLIENT_CREDENTIALS_AUTH_CALL_MADE, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, CC_SYSTEM_CODING_SYSTEM, CC_SYSTEM_SOCIAL_SECURITY_NUMBER, CLIENT_ASSERTION_TYPE_VALUE, @@ -311,15 +312,12 @@ def dispatch(self, request, *args, **kwargs): }, status=HTTPStatus.FORBIDDEN, ) - elif ( - switch_is_active('enable_auditevents') - and self.version != Versions.V3 - and ( - AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') - ) + + if switch_is_active('enable_auditevents') and ( + AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') ): return JsonResponse( - {'status_code': HTTPStatus.BAD_REQUEST, 'message': 'Invalid scopes.'}, + {'status_code': HTTPStatus.BAD_REQUEST, 'message': AUDIT_EVENT_SCOPE_ERROR_MESSAGE}, status=HTTPStatus.BAD_REQUEST, ) @@ -1271,6 +1269,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: if switch_is_active('enable_auditevents') and AUDIT_EVENT_SCOPE not in token.scope: log.info('patient/AuditEvent.rs scope not requested for client_credentials call, adding it') token.scope += ' ' + AUDIT_EVENT_SCOPE + body['scope'] = token.scope token.user_id = user.id token.save() From 8a2a3085af5a1a818a955ce2c7c60d5d722af642 Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 10 Jul 2026 13:40:45 -0400 Subject: [PATCH 14/22] Ensure a AuditEvent/ call returns successfully, add .r and .s scopes. Modify scope param handling for CAN requests --- .../commands/create_blue_button_scopes.py | 46 +++++++++++++++++-- apps/constants.py | 3 ++ apps/dot_ext/constants.py | 7 +-- apps/dot_ext/tests/test_authorization.py | 15 ++++-- apps/dot_ext/tests/test_utils.py | 38 +++++++++++++++ apps/dot_ext/utils.py | 34 ++++++++++++++ apps/dot_ext/views/authorization.py | 18 ++++++-- apps/fhir/bluebutton/permissions.py | 15 ++++++ apps/fhir/bluebutton/v3/urls.py | 7 ++- apps/fhir/bluebutton/views/audit_event.py | 8 ++++ 10 files changed, 174 insertions(+), 17 deletions(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index b84585958..b0e5663d5 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -321,13 +321,50 @@ def create_token_introspect_capability(group): return c -def create_audit_event_capability(group): +def create_audit_event_read_search_capability(group): c = None description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' - title = 'Audit Event FHIR Resource' + title = 'Audit Event FHIR Resource Read/Search' smart_scope_string = 'patient/AuditEvent.rs' protected_resources = [] protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/]?$']) + protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/?].*$']) + + if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists(): + c = ProtectedCapability.objects.create(group=group, + title=title, + description=description, + default=True, + slug=smart_scope_string, + protected_resources=json.dumps(protected_resources, indent=4)) + return c + + +def create_audit_event_read_capability(group): + c = None + description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + title = 'Audit Event FHIR Resource Read' + smart_scope_string = 'patient/AuditEvent.r' + protected_resources = [] + protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/?].*$']) + + if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists(): + c = ProtectedCapability.objects.create(group=group, + title=title, + description=description, + default=True, + slug=smart_scope_string, + protected_resources=json.dumps(protected_resources, indent=4)) + return c + + +def create_audit_event_search_capability(group): + c = None + description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + title = 'Audit Event FHIR Resource Search' + smart_scope_string = 'patient/AuditEvent.s' + protected_resources = [] + protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/]?$']) if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists(): c = ProtectedCapability.objects.create(group=group, @@ -363,4 +400,7 @@ def handle(self, *args, **options): create_token_introspect_capability(g) if switch_is_active('enable_auditevents'): - create_audit_event_capability(g) + create_audit_event_read_search_capability(g) + create_audit_event_read_capability(g) + create_audit_event_search_capability(g) + diff --git a/apps/constants.py b/apps/constants.py index 46f51da42..4404e71ea 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -114,6 +114,8 @@ OPENID_SCOPE = 'openid' LAUNCH_SCOPE = 'launch/patient' AUDIT_EVENT_SCOPE = 'patient/AuditEvent.rs' +AUDIT_EVENT_READ_SCOPE = 'patient/AuditEvent.r' +AUDIT_EVENT_SEARCH_SCOPE = 'patient/AuditEvent.s' OPERATION_OUTCOME = 'OperationOutcome' @@ -147,3 +149,4 @@ FHIR_RES_TYPE_EOB = 'ExplanationOfBenefit' FHIR_RES_TYPE_PATIENT = 'Patient' FHIR_RES_TYPE_COVERAGE = 'Coverage' +FHIR_RES_TYPE_AUDIT_EVENT = 'AuditEvent' diff --git a/apps/dot_ext/constants.py b/apps/dot_ext/constants.py index b0ccaa34c..418509107 100644 --- a/apps/dot_ext/constants.py +++ b/apps/dot_ext/constants.py @@ -1,6 +1,6 @@ import re -from apps.constants import AUDIT_EVENT_SCOPE, LAUNCH_SCOPE, OPENID_SCOPE +from apps.constants import LAUNCH_SCOPE, OPENID_SCOPE # REGEX of paths that should be updated with auth flow info in hhs_oauth_server.request_logging.py AUTH_FLOW_REQUEST_LOGGING_PATHS_REGEX = ( @@ -1649,7 +1649,4 @@ ID_ME_URL_CONTAINS = 'oidc' SECONDS_IN_ONE_HOUR = 3600 -AUDIT_EVENT_SCOPE_ERROR_MESSAGE = ( - AUDIT_EVENT_SCOPE - + ' is only valid in token requests through CMS Aligned Networks. For more information, go to this page: https://bluebutton.cms.gov/cms-aligned-networks-documentation/' -) +AUDIT_EVENT_SCOPE_ERROR_MESSAGE = 'Requesting an AuditEvent scope is only valid in token requests through CMS Aligned Networks. For more information, go to this page: https://bluebutton.cms.gov/cms-aligned-networks-documentation/' diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 5c1a3ee2a..04a2ca1a1 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlencode, urlparse +import pytest import pytz from dateutil.relativedelta import relativedelta from django.db.models import Q @@ -1888,20 +1889,28 @@ def test_fail_when_app_only_allowed_client_credentials(self): ) +@pytest.mark.parametrize( + 'scope', + [ + ('patient/Patient.rs patient/AuditEvent.rs'), + ('patient/Patient.rs patient/AuditEvent.s'), + ('patient/Patient.rs patient/AuditEvent.r'), + ], +) @override_switch('enable_auditevents', True) -def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application): +def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application, scope): """Ensure a bad request 400 error, with message equal to Invalid scopes is raised when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param """ redirect_uri = 'http://localhost' - + print('scope: ', scope) # create an application via fixture application = create_application('an app') payload = { 'client_id': application.client_id, 'response_type': 'code', 'redirect_uri': redirect_uri, - 'scope': ['patient/Patient.rs patient/AuditEvent.rs'], + 'scope': [scope], 'expires_in': 86400, 'allow': True, 'state': '0123456789abcdef', diff --git a/apps/dot_ext/tests/test_utils.py b/apps/dot_ext/tests/test_utils.py index eef8c33b7..402372668 100644 --- a/apps/dot_ext/tests/test_utils.py +++ b/apps/dot_ext/tests/test_utils.py @@ -10,6 +10,7 @@ from apps.dot_ext.models import AuthFlowTracking from apps.dot_ext.utils import ( check_auth_tracking_and_create_access_token_extension, + check_can_token_scope_for_audit_event_scopes, get_api_version_number_from_url, remove_application_user_pair_tokens_data_access, validate_client_id, @@ -235,3 +236,40 @@ def test_validate_client_id_accepts_valid(client_id): # Should not raise with patch.dict(os.environ, {'TARGET_ENV': 'test'}): validate_client_id(client_id) + + +@pytest.mark.parametrize( + 'passed_in_scope, expected_scope', + [ + ( + 'profile patient/Patient.rs patient/AuditEvent.rs patient/AuditEvent.r', + 'profile patient/Patient.rs patient/AuditEvent.rs', + ), + ( + 'profile patient/Patient.rs patient/AuditEvent.rs patient/AuditEvent.s', + 'profile patient/Patient.rs patient/AuditEvent.rs', + ), + ('profile patient/Patient.rs patient/AuditEvent.r', 'profile patient/Patient.rs patient/AuditEvent.rs'), + ('profile patient/Patient.rs patient/AuditEvent.s', 'profile patient/Patient.rs patient/AuditEvent.rs'), + ('profile patient/Patient.rs', 'profile patient/Patient.rs patient/AuditEvent.rs'), + ( + 'profile patient/Patient.rs patient/AuditEvent.s patient/AuditEvent.r', + 'profile patient/Patient.rs patient/AuditEvent.rs', + ), + ( + 'profile patient/Patient.rs patient/AuditEvent.s patient/AuditEvent.r patient/AuditEvent.rs', + 'profile patient/Patient.rs patient/AuditEvent.rs', + ), + ('profile patient/Patient.rs patient/AuditEvent.rs', 'profile patient/Patient.rs patient/AuditEvent.rs'), + ], +) +def test_check_can_token_scope_for_audit_event_scopes(passed_in_scope, expected_scope) -> None: + """Confirm that no matter what combination of AuditEvent scopes is passed into + check_can_token_scope_for_audit_event_scopes, that only patient/AuditEvent.rs is present + on the returned scope. + Args: + passed_in_scope (_type_): scope being passed into check_can_token_scope_for_audit_event_scopes + expected_scope (_type_): expected scope to be returned + """ + scope = check_can_token_scope_for_audit_event_scopes(passed_in_scope) + assert scope == expected_scope diff --git a/apps/dot_ext/utils.py b/apps/dot_ext/utils.py index e06a9dda0..dc78410d9 100644 --- a/apps/dot_ext/utils.py +++ b/apps/dot_ext/utils.py @@ -25,6 +25,9 @@ APPLICATION_ONE_TIME_REFRESH_NOT_ALLOWED_MESG, APPLICATION_TEMPORARILY_INACTIVE, APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG, + AUDIT_EVENT_READ_SCOPE, + AUDIT_EVENT_SCOPE, + AUDIT_EVENT_SEARCH_SCOPE, HHS_SERVER_LOGNAME_FMT, ) from apps.dot_ext.constants import APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_NOT_FOUND_MESG @@ -409,3 +412,34 @@ def check_auth_tracking_and_create_access_token_extension( AccessTokenExtension.objects.get_or_create( access_token=token, include_samhsa=include_samhsa, part_d_eob_only=prior_part_d_eob_only ) + + +def check_can_token_scope_for_audit_event_scopes(scope: str) -> str: + """Check the token being created as a result of a CAN call for AuditEvent scopes. + Currently, we only want to apply patient/AuditEvent.rs as a scope. If .r or .s are on + the token scope, remove those. If patient/AuditEvent.rs is not on the scope, add it. + + Args: + scope (str): The scope parameter that was passed to the CAN token call + + Returns: + str: The scope parameter after AuditEvent checks have been performed + """ + + audit_event_read_pattern = r'patient/AuditEvent\.r\b' + + if re.search(audit_event_read_pattern, scope): + if AUDIT_EVENT_SCOPE in scope: + scope = scope.replace(AUDIT_EVENT_SCOPE, '') + scope = scope.replace(AUDIT_EVENT_READ_SCOPE, '') + + if AUDIT_EVENT_SEARCH_SCOPE in scope: + log.info('patient/AuditEvent.s scope requested for client_credentials call, removing it') + scope = scope.replace(AUDIT_EVENT_SEARCH_SCOPE, '') + + if AUDIT_EVENT_SCOPE not in scope: + log.info('patient/AuditEvent.rs scope not requested for client_credentials call, adding it') + scope += ' ' + AUDIT_EVENT_SCOPE + + # Ensure any extra spaces are filtered + return ' '.join(scope.split()) diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 9c9679395..39ccf6e04 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -64,7 +64,9 @@ from apps.capabilities.models import ProtectedCapability from apps.constants import ( APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET, + AUDIT_EVENT_READ_SCOPE, AUDIT_EVENT_SCOPE, + AUDIT_EVENT_SEARCH_SCOPE, CLIENT_CREDENTIALS, CLIENT_CREDENTIALS_ACCEPTED_JWT_ALGORITHMS, HHS_SERVER_LOGNAME_FMT, @@ -106,6 +108,7 @@ from apps.dot_ext.signals import beneficiary_authorized_application from apps.dot_ext.utils import ( check_auth_tracking_and_create_access_token_extension, + check_can_token_scope_for_audit_event_scopes, get_api_version_number_from_url, json_response_from_oauth2_error, remove_application_user_pair_tokens_data_access, @@ -314,7 +317,12 @@ def dispatch(self, request, *args, **kwargs): ) if switch_is_active('enable_auditevents') and ( - AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') + AUDIT_EVENT_SCOPE in request.GET.get('scope', '') + or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') + or AUDIT_EVENT_READ_SCOPE in request.GET.get('scope', '') + or AUDIT_EVENT_READ_SCOPE in request.POST.get('scope', '') + or AUDIT_EVENT_SEARCH_SCOPE in request.GET.get('scope', '') + or AUDIT_EVENT_SEARCH_SCOPE in request.POST.get('scope', '') ): return JsonResponse( {'status_code': HTTPStatus.BAD_REQUEST, 'message': AUDIT_EVENT_SCOPE_ERROR_MESSAGE}, @@ -1265,10 +1273,10 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: body['refresh_token'] = refresh_token.token - # Even if the patient/AuditEvent.rs scope is not included in the request, add it to the token - if switch_is_active('enable_auditevents') and AUDIT_EVENT_SCOPE not in token.scope: - log.info('patient/AuditEvent.rs scope not requested for client_credentials call, adding it') - token.scope += ' ' + AUDIT_EVENT_SCOPE + # If the enable_auditevents switch is active, make sure the only AuditEvent scope + # that the token has is patient/AuditEvent.rs + if switch_is_active('enable_auditevents'): + token.scope = check_can_token_scope_for_audit_event_scopes(token.scope) body['scope'] = token.scope token.user_id = user.id diff --git a/apps/fhir/bluebutton/permissions.py b/apps/fhir/bluebutton/permissions.py index a047bc4a2..c0382a440 100644 --- a/apps/fhir/bluebutton/permissions.py +++ b/apps/fhir/bluebutton/permissions.py @@ -13,6 +13,8 @@ APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET, APPLICATION_DOES_NOT_HAVE_VALID_SCOPES, APPLICATION_TEMPORARILY_INACTIVE, + AUDIT_EVENT_SCOPE, + FHIR_RES_TYPE_AUDIT_EVENT, FHIR_RES_TYPE_COVERAGE, FHIR_RES_TYPE_EOB, FHIR_RES_TYPE_PATIENT, @@ -64,6 +66,13 @@ def has_object_permission(self, request, view, obj): reference_id = reference.split('/')[1] if reference_id != fhir_id: raise exceptions.NotFound() + elif request.resource_type == FHIR_RES_TYPE_AUDIT_EVENT: + entity = obj.get('entity', [{}]) + patient_info = entity[0].get('what', {}) + reference = patient_info.get('reference', '') + reference_id = reference.split('/')[1] + if reference_id != fhir_id: + raise exceptions.NotFound() else: reference_id = obj['id'] if reference_id != fhir_id: @@ -165,6 +174,12 @@ def has_permission(self, request: Request, view) -> bool: raise PermissionDenied( APPLICATION_DOES_NOT_HAVE_VALID_SCOPES.format(token.application, 'any', 'digital insurance card') ) + # If we ever decide we want to put patient/AuditEvent scopes on applications, this elif will need to be modified + # We should be able to just use the else block, as long as we correctly add the scopes to READ_SEARCH_SCOPE_LOOKUP + elif request.resource_type == FHIR_RES_TYPE_AUDIT_EVENT: + if AUDIT_EVENT_SCOPE in token.scope: + return True + return False else: # Determine if scopes from database have correct permission if view was search/read FHIR call resource_set = set(READ_SEARCH_SCOPE_LOOKUP[request.resource_type][request_type]) diff --git a/apps/fhir/bluebutton/v3/urls.py b/apps/fhir/bluebutton/v3/urls.py index 332fff94d..4b249d387 100644 --- a/apps/fhir/bluebutton/v3/urls.py +++ b/apps/fhir/bluebutton/v3/urls.py @@ -2,7 +2,7 @@ from django.urls import re_path from waffle.decorators import waffle_switch -from apps.fhir.bluebutton.views.audit_event import AuditEventView +from apps.fhir.bluebutton.views.audit_event import AuditEventView, ReadViewAuditEventView from apps.fhir.bluebutton.views.insurancecard import DigitalInsuranceCardView from apps.fhir.bluebutton.views.read import ( ReadViewCoverage, @@ -60,6 +60,11 @@ waffle_switch('v3_endpoints')(SearchViewExplanationOfBenefit.as_view(version=3)), name='bb_oauth_fhir_eob_search_v3', ), + re_path( + r'AuditEvent/(?P[^/]+)', + waffle_switch('enable_auditevents')(waffle_switch('v3_endpoints')(ReadViewAuditEventView.as_view(version=3))), + name='bb_oauth_fhir_audit_event_read', + ), # Audit Events View re_path( r'AuditEvent[/]?', diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index 59b34ee35..a613b2ce1 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -8,6 +8,7 @@ SearchCrosswalkPermission, ) from apps.fhir.bluebutton.views.generic import FhirDataView +from apps.fhir.bluebutton.views.read import ReadView from apps.versions import Versions @@ -49,3 +50,10 @@ def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwa fhir_url = fhir_settings.fhir_url return f'{fhir_url}/v{self.version}/fhir/AuditEvent' + + +class ReadViewAuditEventView(ReadView): + # Class used for AuditEvent resource + def __init__(self, version=1): + super().__init__(version) + self.resource_type = 'AuditEvent' From 635b3e5292c3f3ce78fbf15c85b4bfaa99e31549 Mon Sep 17 00:00:00 2001 From: James Demery Date: Fri, 10 Jul 2026 16:30:04 -0400 Subject: [PATCH 15/22] Remove print --- apps/dot_ext/tests/test_authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 04a2ca1a1..9f242ab91 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1903,7 +1903,7 @@ def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application, when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param """ redirect_uri = 'http://localhost' - print('scope: ', scope) + # create an application via fixture application = create_application('an app') payload = { From 805b2f1132972712a3d9dc68a41f3a8cc8602a4a Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 13 Jul 2026 12:08:12 -0400 Subject: [PATCH 16/22] Address PR feedback --- .../management/commands/create_blue_button_scopes.py | 6 +++--- apps/dot_ext/utils.py | 7 +++---- apps/fhir/bluebutton/tests/test_audit_event.py | 8 ++++---- apps/fhir/bluebutton/v3/urls.py | 2 +- apps/fhir/bluebutton/views/audit_event.py | 2 +- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index b0e5663d5..8974a1b80 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -323,7 +323,7 @@ def create_token_introspect_capability(group): def create_audit_event_read_search_capability(group): c = None - description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + description = 'Allow CAN patients and 3rd party apps to read and search audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' title = 'Audit Event FHIR Resource Read/Search' smart_scope_string = 'patient/AuditEvent.rs' protected_resources = [] @@ -342,7 +342,7 @@ def create_audit_event_read_search_capability(group): def create_audit_event_read_capability(group): c = None - description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + description = 'Allow CAN patients and 3rd party apps to read audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' title = 'Audit Event FHIR Resource Read' smart_scope_string = 'patient/AuditEvent.r' protected_resources = [] @@ -360,7 +360,7 @@ def create_audit_event_read_capability(group): def create_audit_event_search_capability(group): c = None - description = 'Allow CAN patients and 3rd party apps to retrieve audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' + description = 'Allow CAN patients and 3rd party apps to search audit event data that shows what apps have had a successful patient match for network calls via the Blue Button API (CAN flow).' title = 'Audit Event FHIR Resource Search' smart_scope_string = 'patient/AuditEvent.s' protected_resources = [] diff --git a/apps/dot_ext/utils.py b/apps/dot_ext/utils.py index dc78410d9..d4d7aaca2 100644 --- a/apps/dot_ext/utils.py +++ b/apps/dot_ext/utils.py @@ -25,7 +25,6 @@ APPLICATION_ONE_TIME_REFRESH_NOT_ALLOWED_MESG, APPLICATION_TEMPORARILY_INACTIVE, APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG, - AUDIT_EVENT_READ_SCOPE, AUDIT_EVENT_SCOPE, AUDIT_EVENT_SEARCH_SCOPE, HHS_SERVER_LOGNAME_FMT, @@ -428,10 +427,10 @@ def check_can_token_scope_for_audit_event_scopes(scope: str) -> str: audit_event_read_pattern = r'patient/AuditEvent\.r\b' + # We need a different strategy for replacing patient/AuditEvent.r as it is a substring + # of the patient/AuditEvent.rs scope. That is why regex is used. if re.search(audit_event_read_pattern, scope): - if AUDIT_EVENT_SCOPE in scope: - scope = scope.replace(AUDIT_EVENT_SCOPE, '') - scope = scope.replace(AUDIT_EVENT_READ_SCOPE, '') + scope = re.sub(audit_event_read_pattern, '', scope) if AUDIT_EVENT_SEARCH_SCOPE in scope: log.info('patient/AuditEvent.s scope requested for client_credentials call, removing it') diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index efc32f949..2dbc92a56 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -28,7 +28,7 @@ def test_audit_event_call_without_audit_event_scope(basic_user, get_access_token ) response = client.get( - reverse('bb_oauth_fhir_audit_event'), + reverse('bb_oauth_fhir_audit_event_search'), Authorization='Bearer %s' % (access_token), ) @@ -73,7 +73,7 @@ def test_audit_event_call_without_v3_endpoints_enabled(basic_user, get_access_to ) response = client.get( - reverse('bb_oauth_fhir_audit_event'), + reverse('bb_oauth_fhir_audit_event_search'), Authorization='Bearer %s' % (access_token), ) @@ -113,7 +113,7 @@ def catchall(url, req): return {'status_code': HTTPStatus.OK, 'content': audit_event_bundle} with HTTMock(catchall): - response = client.get(reverse('bb_oauth_fhir_audit_event'), Authorization='Bearer %s' % (access_token)) + response = client.get(reverse('bb_oauth_fhir_audit_event_search'), Authorization='Bearer %s' % (access_token)) json_response = response.json() audit_event_resource = json_response.get('entry')[0].get('resource') @@ -144,7 +144,7 @@ def test_successful_audit_event_call(basic_user, get_access_token, create_capabi create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/]?$']]) response = client.get( - reverse('bb_oauth_fhir_audit_event'), + reverse('bb_oauth_fhir_audit_event_search'), Authorization='Bearer %s' % (access_token), ) diff --git a/apps/fhir/bluebutton/v3/urls.py b/apps/fhir/bluebutton/v3/urls.py index 4b249d387..cdb1e2080 100644 --- a/apps/fhir/bluebutton/v3/urls.py +++ b/apps/fhir/bluebutton/v3/urls.py @@ -69,6 +69,6 @@ re_path( r'AuditEvent[/]?', waffle_switch('enable_auditevents')(waffle_switch('v3_endpoints')(AuditEventView.as_view(version=3))), - name='bb_oauth_fhir_audit_event', + name='bb_oauth_fhir_audit_event_search', ), ] diff --git a/apps/fhir/bluebutton/views/audit_event.py b/apps/fhir/bluebutton/views/audit_event.py index a613b2ce1..ae27be2df 100644 --- a/apps/fhir/bluebutton/views/audit_event.py +++ b/apps/fhir/bluebutton/views/audit_event.py @@ -49,7 +49,7 @@ def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwa else: fhir_url = fhir_settings.fhir_url - return f'{fhir_url}/v{self.version}/fhir/AuditEvent' + return f'{fhir_url}/v3/fhir/AuditEvent' class ReadViewAuditEventView(ReadView): From a82df4f59e54e303af01d56579ddcffe5c38c721 Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 13 Jul 2026 12:21:28 -0400 Subject: [PATCH 17/22] Add integration test for AuditEvent read --- apps/constants.py | 2 + .../fhir/bluebutton/tests/test_audit_event.py | 42 ++++++++++++++++++- apps/fhir/bluebutton/tests/test_utils.py | 4 +- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/constants.py b/apps/constants.py index 4404e71ea..c607217fe 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -150,3 +150,5 @@ FHIR_RES_TYPE_PATIENT = 'Patient' FHIR_RES_TYPE_COVERAGE = 'Coverage' FHIR_RES_TYPE_AUDIT_EVENT = 'AuditEvent' + +SAMPLE_CAN_USER_FHIR_ID_V3 = '-502120048' diff --git a/apps/fhir/bluebutton/tests/test_audit_event.py b/apps/fhir/bluebutton/tests/test_audit_event.py index 2dbc92a56..a55b690cc 100644 --- a/apps/fhir/bluebutton/tests/test_audit_event.py +++ b/apps/fhir/bluebutton/tests/test_audit_event.py @@ -7,6 +7,7 @@ from httmock import HTTMock, all_requests from waffle.testutils import override_switch +from apps.constants import SAMPLE_CAN_USER_FHIR_ID_V3 from apps.integration_tests.constants import MESSAGE_NO_PERMISSION client = Client() @@ -97,7 +98,7 @@ def test_mock_successful_audit_event_call(basic_user, get_access_token, create_c first_name='Damon', last_name='Mychart', fhir_id_v2='custom_fhir_id', - fhir_id_v3='-502120048', + fhir_id_v3=SAMPLE_CAN_USER_FHIR_ID_V3, ) access_token = get_access_token( user.username, @@ -121,7 +122,7 @@ def catchall(url, req): assert response.status_code == HTTPStatus.OK assert json_response.get('resourceType') == 'Bundle' assert audit_event_resource.get('resourceType') == 'AuditEvent' - assert audit_event_resource.get('id') == '-502120048-20260706192218527441255' + assert audit_event_resource.get('id') == SAMPLE_CAN_USER_FHIR_ID_V3 + '-20260706192218527441255' @pytest.mark.integration @@ -150,3 +151,40 @@ def test_successful_audit_event_call(basic_user, get_access_token, create_capabi assert response.status_code == HTTPStatus.OK assert response.json()['resourceType'] == 'Bundle' + + +@pytest.mark.integration +@override_switch('v3_endpoints', active=True) +@override_switch('require-scopes', active=True) +@override_switch('enable_auditevents', active=True) +def test_successful_audit_event_read_call(basic_user, get_access_token, create_capability): + """Make an actual live call to BFD for the AuditEvent read endpoint that returns a 200 + + Args: + basic_user: Fixture for a basic_user + get_access_token: Fixture to create an access token + create_capability: Fixture to create a capabilities_protectedcapability record + """ + # user = basic_user() + user = basic_user( + username='damonmychart', + first_name='Damon', + last_name='MyChart', + fhir_id_v3=SAMPLE_CAN_USER_FHIR_ID_V3, + ) + access_token = get_access_token( + user.username, + 'patient/ExplanationOfBenefit.rs patient/Patient.rs patient/Coverage.rs patient/AuditEvent.rs', + ) + create_capability('patient/AuditEvent.rs', [['GET', '/v[3]/fhir/AuditEvent[/?].*$']]) + + response = client.get( + reverse( + 'bb_oauth_fhir_audit_event_read', + kwargs={'resource_id': SAMPLE_CAN_USER_FHIR_ID_V3 + '-20260702123504429756967'}, + ), + Authorization='Bearer %s' % (access_token), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()['resourceType'] == 'AuditEvent' diff --git a/apps/fhir/bluebutton/tests/test_utils.py b/apps/fhir/bluebutton/tests/test_utils.py index c569d7118..27e994870 100644 --- a/apps/fhir/bluebutton/tests/test_utils.py +++ b/apps/fhir/bluebutton/tests/test_utils.py @@ -16,7 +16,7 @@ ) from apps.accounts.models import UserProfile -from apps.constants import OPERATION_OUTCOME, USER_TYPE_DEV +from apps.constants import OPERATION_OUTCOME, SAMPLE_CAN_USER_FHIR_ID_V3, USER_TYPE_DEV from apps.fhir.bluebutton.models import Crosswalk from apps.fhir.bluebutton.utils import ( FhirServerAuth, @@ -619,7 +619,7 @@ def test_extract_fhir_id_successful(self): patient = bundle.entry[1].resource result = extract_fhir_id_from_patient(patient) - assert result == '-502120048' + assert result == SAMPLE_CAN_USER_FHIR_ID_V3 def test_extract_fhir_id_unsuccessful(self): """ From b84c67eb0a16eb8a2ebc1c562b393bbdcccba48e Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 13 Jul 2026 16:16:43 -0400 Subject: [PATCH 18/22] Changes so that AuditEvent functionality works with the protectedcapability records having default = False --- .../commands/create_blue_button_scopes.py | 6 +- apps/constants.py | 1 + apps/dot_ext/oauth2_validators.py | 26 ++++++- apps/dot_ext/tests/test_authorization.py | 72 ++++++++++++------- apps/dot_ext/views/authorization.py | 2 +- 5 files changed, 76 insertions(+), 31 deletions(-) diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index 8974a1b80..97910f797 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -334,7 +334,7 @@ def create_audit_event_read_search_capability(group): c = ProtectedCapability.objects.create(group=group, title=title, description=description, - default=True, + default=False, slug=smart_scope_string, protected_resources=json.dumps(protected_resources, indent=4)) return c @@ -352,7 +352,7 @@ def create_audit_event_read_capability(group): c = ProtectedCapability.objects.create(group=group, title=title, description=description, - default=True, + default=False, slug=smart_scope_string, protected_resources=json.dumps(protected_resources, indent=4)) return c @@ -370,7 +370,7 @@ def create_audit_event_search_capability(group): c = ProtectedCapability.objects.create(group=group, title=title, description=description, - default=True, + default=False, slug=smart_scope_string, protected_resources=json.dumps(protected_resources, indent=4)) return c diff --git a/apps/constants.py b/apps/constants.py index c607217fe..7aa907c39 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -116,6 +116,7 @@ AUDIT_EVENT_SCOPE = 'patient/AuditEvent.rs' AUDIT_EVENT_READ_SCOPE = 'patient/AuditEvent.r' AUDIT_EVENT_SEARCH_SCOPE = 'patient/AuditEvent.s' +AUDIT_EVENT_SCOPE_SET = {AUDIT_EVENT_SCOPE, AUDIT_EVENT_READ_SCOPE, AUDIT_EVENT_SEARCH_SCOPE} OPERATION_OUTCOME = 'OperationOutcome' diff --git a/apps/dot_ext/oauth2_validators.py b/apps/dot_ext/oauth2_validators.py index b6d225e51..53d499797 100644 --- a/apps/dot_ext/oauth2_validators.py +++ b/apps/dot_ext/oauth2_validators.py @@ -6,7 +6,7 @@ from oauthlib.oauth2.rfc6749 import utils from oauthlib.oauth2.rfc6749.errors import InvalidGrantError -from apps.constants import CLIENT_CREDENTIALS +from apps.constants import AUDIT_EVENT_SCOPE_SET, CLIENT_CREDENTIALS from apps.dot_ext.scopes import CapabilitiesScopes from apps.pkce.oauth2_validators import PKCEValidatorMixin @@ -104,3 +104,27 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs): return super().get_original_scopes(refresh_token, request, *args, **kwargs) except ObjectDoesNotExist: raise InvalidGrantError + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + # If the grant_type is 'client_credentials', execute some custom scopes handling + if request.grant_type == CLIENT_CREDENTIALS: + # Grab a list of the scopes that do not include any AuditEvent scopes. If any scopes + # are in the resulting list, we will pass those scopes to the OAuth2Validator.validate_scopes + # to be checked as scopes normally are + scopes_with_audit_event_filtered_out = list(set(scopes) - AUDIT_EVENT_SCOPE_SET) + + # Grab the intersection of the requested scopes and AuditEvent scopes. This value is then used + # if the only scopes included in the request were AuditEvent scopes. If there were only AuditEvent + # scopes in the request, the conditional for scopes_with_audit_event_filtered_out will not evaluate + # and we will need to return based on if there are any AuditEvent scopes. We want to allow + # client_credentials calls through, even if they only have AuditEvent scopes, + audit_event_scopes_in_request = set(scopes) & AUDIT_EVENT_SCOPE_SET + + if scopes_with_audit_event_filtered_out: + return super().validate_scopes( + client_id, scopes_with_audit_event_filtered_out, client, request, *args, **kwargs + ) + return bool(audit_event_scopes_in_request) + + # For refresh_token and authorization-code grant types, validate scopes as normal + return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 9f242ab91..daad6671d 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1890,34 +1890,54 @@ def test_fail_when_app_only_allowed_client_credentials(self): @pytest.mark.parametrize( - 'scope', + 'scope, auth_url, enable_auditevents_switch_active', [ - ('patient/Patient.rs patient/AuditEvent.rs'), - ('patient/Patient.rs patient/AuditEvent.s'), - ('patient/Patient.rs patient/AuditEvent.r'), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', True), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', True), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', True), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', True), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', True), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', True), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', True), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', True), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', True), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', False), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', False), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', False), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', False), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', False), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', False), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', False), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', False), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', False), ], ) -@override_switch('enable_auditevents', True) -def test_failure_on_authorize_non_v3_with_audit_event_scope(create_application, scope): +@override_switch('v3_endpoints', active=True) +def test_failure_on_authorize_non_v3_with_audit_event_scope( + create_application, scope, auth_url, enable_auditevents_switch_active +): """Ensure a bad request 400 error, with message equal to Invalid scopes is raised - when there is a non-v3 auth request that includes patient/AuditEvent.rs in the scopes param + when there is a v1, 2, or 3 auth request that includes any AuditEvemnt scope in the scopes param """ - redirect_uri = 'http://localhost' - - # create an application via fixture - application = create_application('an app') - payload = { - 'client_id': application.client_id, - 'response_type': 'code', - 'redirect_uri': redirect_uri, - 'scope': [scope], - 'expires_in': 86400, - 'allow': True, - 'state': '0123456789abcdef', - 'code_challenge': 'sZrievZsrYqxdnu2NVD603EiYBM18CuzZpwB-pOSZjo', - 'code_challenge_method': CODE_CHALLENGE_METHOD_S256, - } - response = Client().post(reverse('oauth2_provider:authorize'), data=payload) - - assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.json()['message'] == AUDIT_EVENT_SCOPE_ERROR_MESSAGE + with override_switch('enable_auditevents', active=enable_auditevents_switch_active): + redirect_uri = 'http://localhost' + + # create an application via fixture + application = create_application('an app') + payload = { + 'client_id': application.client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': [scope], + 'expires_in': 86400, + 'allow': True, + 'state': '0123456789abcdef', + 'code_challenge': 'sZrievZsrYqxdnu2NVD603EiYBM18CuzZpwB-pOSZjo', + 'code_challenge_method': CODE_CHALLENGE_METHOD_S256, + } + print('what is the reverse: ', reverse(auth_url)) + print('scope coming through? ', scope) + response = Client().post(reverse(auth_url), data=payload) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json()['message'] == AUDIT_EVENT_SCOPE_ERROR_MESSAGE diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 39ccf6e04..2a1701cd2 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -316,7 +316,7 @@ def dispatch(self, request, *args, **kwargs): status=HTTPStatus.FORBIDDEN, ) - if switch_is_active('enable_auditevents') and ( + if ( AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') or AUDIT_EVENT_READ_SCOPE in request.GET.get('scope', '') From 6eb677cf7d10fe20bb42f2503a4a8f95d0c5c3c9 Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 13 Jul 2026 16:22:20 -0400 Subject: [PATCH 19/22] Remove prints --- apps/dot_ext/tests/test_authorization.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index daad6671d..4895ea8f1 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1935,8 +1935,7 @@ def test_failure_on_authorize_non_v3_with_audit_event_scope( 'code_challenge': 'sZrievZsrYqxdnu2NVD603EiYBM18CuzZpwB-pOSZjo', 'code_challenge_method': CODE_CHALLENGE_METHOD_S256, } - print('what is the reverse: ', reverse(auth_url)) - print('scope coming through? ', scope) + response = Client().post(reverse(auth_url), data=payload) assert response.status_code == HTTPStatus.BAD_REQUEST From d777e20fd528c22a8b3d579b2aa65580188a5351 Mon Sep 17 00:00:00 2001 From: James Demery Date: Mon, 13 Jul 2026 16:22:54 -0400 Subject: [PATCH 20/22] Make comment clearer --- apps/dot_ext/tests/test_authorization.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index 4895ea8f1..aae02ffd2 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1917,7 +1917,8 @@ def test_failure_on_authorize_non_v3_with_audit_event_scope( create_application, scope, auth_url, enable_auditevents_switch_active ): """Ensure a bad request 400 error, with message equal to Invalid scopes is raised - when there is a v1, 2, or 3 auth request that includes any AuditEvemnt scope in the scopes param + when there is a v1, 2, or 3 auth request that includes any AuditEvent scope in the scopes param + and regardless of if the enable_auditevents switch is true or false """ with override_switch('enable_auditevents', active=enable_auditevents_switch_active): redirect_uri = 'http://localhost' From e1f4f1c974bf85381804b19bfc540c7c696b309e Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 14 Jul 2026 08:32:29 -0400 Subject: [PATCH 21/22] Add tests, modify comments --- apps/dot_ext/tests/test_oauth2_validators.py | 50 ++++++++++++++++++-- apps/dot_ext/tests/test_views.py | 15 +++++- apps/dot_ext/views/authorization.py | 3 +- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/dot_ext/tests/test_oauth2_validators.py b/apps/dot_ext/tests/test_oauth2_validators.py index 9c406c1f3..1313d50c1 100644 --- a/apps/dot_ext/tests/test_oauth2_validators.py +++ b/apps/dot_ext/tests/test_oauth2_validators.py @@ -1,9 +1,13 @@ +from unittest.mock import patch + +import pytest from django.core.exceptions import ValidationError from django.http import HttpRequest from django.test import TestCase -from unittest.mock import patch -from apps.dot_ext.oauth2_validators import OAuth2Validator +from apps.constants import CLIENT_CREDENTIALS, REFRESH_TOKEN, TEST_APP_CLIENT_ID +from apps.dot_ext.constants import V2_SCOPES_ALL +from apps.dot_ext.oauth2_validators import OAuth2Validator, SingleAccessTokenValidator from apps.dot_ext.validators import validate_uris PATIENT_COVERAGE_SCOPES = 'patient/Patient.rs patient/Coverage.rs' @@ -64,7 +68,9 @@ def test_is_within_original_scope_invalid_request_profile(self): def test_is_within_original_scope_valid_request_profile(self): validator = OAuth2Validator() request = HttpRequest() - with patch.object(validator, 'get_original_scopes', return_value='profile patient/Patient.rs patient/Coverage.rs'): + with patch.object( + validator, 'get_original_scopes', return_value='profile patient/Patient.rs patient/Coverage.rs' + ): result = validator.is_within_original_scope(['profile'], object(), request) assert result @@ -90,3 +96,41 @@ def test_is_within_original_scope_invalid_request_read_when_access_token_had_sea result = validator.is_within_original_scope(['patient/ExplanationOfBenefit.r'], object(), request) assert not result + + +@pytest.mark.django_db +@pytest.mark.parametrize( + 'scopes, grant_type', + [ + (['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], CLIENT_CREDENTIALS), + (['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], CLIENT_CREDENTIALS), + (['patient/AuditEvent.rs', 'patient/Patient.rs'], CLIENT_CREDENTIALS), + (['patient/AuditEvent.r'], CLIENT_CREDENTIALS), + (['patient/AuditEvent.rs'], CLIENT_CREDENTIALS), + (['profile', 'patient/Coverage.r'], CLIENT_CREDENTIALS), + (['profile', 'patient/Coverage.r'], CLIENT_CREDENTIALS), + (['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], REFRESH_TOKEN), + (['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], REFRESH_TOKEN), + (['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], 'authorization-code'), + (['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], 'authorization-code'), + ], +) +def test_validate_scopes(create_application, scopes, grant_type): + """Ensure that the overwritten validate_scopes function processes our standard scopes (EOB, Coverage, Patient) + and the AuditEvent scopes correctly. AuditEvent scopes have default equal to false and are not added to applications + (as of July 2026), so we have custom handling for those. + + Args: + scopes: List of scopes we are validating will go through the overwritten OAuth2 function + successfully + """ + validator = SingleAccessTokenValidator() + with patch.object(OAuth2Validator, 'validate_scopes', return_value=True): + with patch.object(validator, 'get_original_scopes', return_value=V2_SCOPES_ALL): + app = create_application('TestApp') + request = HttpRequest() + request.grant_type = grant_type + client_id = TEST_APP_CLIENT_ID + result = validator.validate_scopes(client_id, scopes, app, request) + + assert result diff --git a/apps/dot_ext/tests/test_views.py b/apps/dot_ext/tests/test_views.py index 6050cd644..dab033e95 100644 --- a/apps/dot_ext/tests/test_views.py +++ b/apps/dot_ext/tests/test_views.py @@ -18,7 +18,12 @@ from apps.authorization.models import ArchivedDataAccessGrant, DataAccessGrant from apps.capabilities.models import ProtectedCapability -from apps.constants import AUDIT_EVENT_SCOPE, DEFAULT_SAMPLE_FHIR_ID_V2 +from apps.constants import ( + AUDIT_EVENT_READ_SCOPE, + AUDIT_EVENT_SCOPE, + AUDIT_EVENT_SEARCH_SCOPE, + DEFAULT_SAMPLE_FHIR_ID_V2, +) from apps.dot_ext.constants import ( BENE_PERSONAL_INFO_SCOPES, SCOPES_TO_URL_BASE_PATH, @@ -77,9 +82,15 @@ def test_assigns_demographic_scopes(self): default_scopes = ProtectedCapability.objects.filter(default=True) self.assertQuerySetEqual(app.scope.all(), default_scopes, ordered=False) - audit_event_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_SCOPE) + audit_event_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_SCOPE, default=False) assert audit_event_scope.exists() + audit_event_read_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_READ_SCOPE, default=False) + assert audit_event_read_scope.exists() + + audit_event_search_scope = ProtectedCapability.objects.filter(slug=AUDIT_EVENT_SEARCH_SCOPE, default=False) + assert audit_event_search_scope.exists() + def test_does_not_assign_demographic_scopes(self): """ Assert that the registration view does not assign demographic scopes when the diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 2a1701cd2..41888352e 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -302,8 +302,6 @@ def dispatch(self, request, *args, **kwargs): path_info = self.request.__dict__.get('path_info') version = get_api_version_number_from_url(path_info) # Validate that the app is able to make v3 calls if it is a v3 call - # Otherwise, confirm patient/AuditEvent.rs is not in the scope parameter - # if it is a non-v3 call. if version == Versions.V3: try: self.validate_v3_authorization_request() @@ -316,6 +314,7 @@ def dispatch(self, request, *args, **kwargs): status=HTTPStatus.FORBIDDEN, ) + # Confirm there are no AuditEvent scopes in the request. Fail the request if there are if ( AUDIT_EVENT_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SCOPE in request.POST.get('scope', '') From a35d13e67cb9986110b953103d51ecfc2832592f Mon Sep 17 00:00:00 2001 From: James Demery Date: Tue, 14 Jul 2026 10:20:04 -0400 Subject: [PATCH 22/22] Address PR feedback --- apps/dot_ext/oauth2_validators.py | 12 ++-- apps/dot_ext/tests/test_authorization.py | 77 +++++++++++++++++------- apps/dot_ext/views/authorization.py | 5 +- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/apps/dot_ext/oauth2_validators.py b/apps/dot_ext/oauth2_validators.py index 53d499797..8f62d35fa 100644 --- a/apps/dot_ext/oauth2_validators.py +++ b/apps/dot_ext/oauth2_validators.py @@ -113,17 +113,15 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): # to be checked as scopes normally are scopes_with_audit_event_filtered_out = list(set(scopes) - AUDIT_EVENT_SCOPE_SET) - # Grab the intersection of the requested scopes and AuditEvent scopes. This value is then used - # if the only scopes included in the request were AuditEvent scopes. If there were only AuditEvent - # scopes in the request, the conditional for scopes_with_audit_event_filtered_out will not evaluate - # and we will need to return based on if there are any AuditEvent scopes. We want to allow - # client_credentials calls through, even if they only have AuditEvent scopes, - audit_event_scopes_in_request = set(scopes) & AUDIT_EVENT_SCOPE_SET - if scopes_with_audit_event_filtered_out: return super().validate_scopes( client_id, scopes_with_audit_event_filtered_out, client, request, *args, **kwargs ) + + # If this code executes, there are no non-AuditEvent scopes in the request. To determine if the requested + # scopes are valid, get the intersection of requested scopes and the AUDIT_EVENT_SCOPE_SET. If there is any + # intersection, this will return True, if not, False. + audit_event_scopes_in_request = set(scopes) & AUDIT_EVENT_SCOPE_SET return bool(audit_event_scopes_in_request) # For refresh_token and authorization-code grant types, validate scopes as normal diff --git a/apps/dot_ext/tests/test_authorization.py b/apps/dot_ext/tests/test_authorization.py index aae02ffd2..b0e5275dd 100644 --- a/apps/dot_ext/tests/test_authorization.py +++ b/apps/dot_ext/tests/test_authorization.py @@ -1890,31 +1890,66 @@ def test_fail_when_app_only_allowed_client_credentials(self): @pytest.mark.parametrize( - 'scope, auth_url, enable_auditevents_switch_active', + 'scope, auth_url, enable_auditevents_switch_active, expected_message', [ - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', True), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', True), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', True), - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', True), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', True), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', True), - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', True), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', True), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', True), - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', False), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', False), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', False), - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', False), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', False), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', False), - ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', False), - ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', False), - ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', False), + ( + 'patient/Patient.rs patient/AuditEvent.rs', + 'oauth2_provider:authorize', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', True, AUDIT_EVENT_SCOPE_ERROR_MESSAGE), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', True, AUDIT_EVENT_SCOPE_ERROR_MESSAGE), + ( + 'patient/Patient.rs patient/AuditEvent.rs', + 'oauth2_provider_v2:authorize-v2', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ( + 'patient/Patient.rs patient/AuditEvent.s', + 'oauth2_provider_v2:authorize-v2', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ( + 'patient/Patient.rs patient/AuditEvent.r', + 'oauth2_provider_v2:authorize-v2', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ( + 'patient/Patient.rs patient/AuditEvent.rs', + 'oauth2_provider_v3:authorize-v3', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ( + 'patient/Patient.rs patient/AuditEvent.s', + 'oauth2_provider_v3:authorize-v3', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ( + 'patient/Patient.rs patient/AuditEvent.r', + 'oauth2_provider_v3:authorize-v3', + True, + AUDIT_EVENT_SCOPE_ERROR_MESSAGE, + ), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'), + ('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'), ], ) @override_switch('v3_endpoints', active=True) def test_failure_on_authorize_non_v3_with_audit_event_scope( - create_application, scope, auth_url, enable_auditevents_switch_active + create_application, scope, auth_url, enable_auditevents_switch_active, expected_message ): """Ensure a bad request 400 error, with message equal to Invalid scopes is raised when there is a v1, 2, or 3 auth request that includes any AuditEvent scope in the scopes param @@ -1940,4 +1975,4 @@ def test_failure_on_authorize_non_v3_with_audit_event_scope( response = Client().post(reverse(auth_url), data=payload) assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.json()['message'] == AUDIT_EVENT_SCOPE_ERROR_MESSAGE + assert response.json()['message'] == expected_message diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 41888352e..3d6dc5ff1 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -323,8 +323,11 @@ def dispatch(self, request, *args, **kwargs): or AUDIT_EVENT_SEARCH_SCOPE in request.GET.get('scope', '') or AUDIT_EVENT_SEARCH_SCOPE in request.POST.get('scope', '') ): + message = AUDIT_EVENT_SCOPE_ERROR_MESSAGE + if not switch_is_active('enable_auditevents'): + message = 'Invalid scopes.' return JsonResponse( - {'status_code': HTTPStatus.BAD_REQUEST, 'message': AUDIT_EVENT_SCOPE_ERROR_MESSAGE}, + {'status_code': HTTPStatus.BAD_REQUEST, 'message': message}, status=HTTPStatus.BAD_REQUEST, )