Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc48a19
BB2-4694: Add support for the v3/fhir/AuditEvent endpoint
JamesDemeryNava Jul 2, 2026
660f524
Add AuditEvent handling to is_resource_for_patient - avoid 404s
JamesDemeryNava Jul 2, 2026
e55a8fa
4695 piece of this, add protected capability and scopes for AuditEvent
JamesDemeryNava Jul 2, 2026
b139522
Switch to default to True for new AuditEvent capability
JamesDemeryNava Jul 2, 2026
a764aa8
Modify unit tests to cover new functionality and add integration tests
JamesDemeryNava Jul 6, 2026
3ede14e
Throw 400 on v2 auth request if AuditEvent scope included, add/modify…
JamesDemeryNava Jul 6, 2026
424ba42
Modify tests and basic_user fixture
JamesDemeryNava Jul 7, 2026
e1f4b83
Add an application fixture, modify tests, add docstrings
JamesDemeryNava Jul 7, 2026
a9ea6af
Rebase and fix conflicts
JamesDemeryNava Jul 7, 2026
42ded52
Requirement updates
JamesDemeryNava Jul 7, 2026
0690109
Clean up doc strings
JamesDemeryNava Jul 7, 2026
8a993a4
Changes per Copilot PR feedback
JamesDemeryNava Jul 7, 2026
187e957
Merge branch 'master' into jamesdemery/bb2-4964-audit-event-endpoint
JamesDemeryNava Jul 7, 2026
bb5ddf7
Incorporate changes from 4963 being merged
JamesDemeryNava Jul 7, 2026
73e5f63
Merge branch 'master' into jamesdemery/bb2-4964-audit-event-endpoint
JamesDemeryNava Jul 7, 2026
a043d84
Merge branch 'master' into jamesdemery/bb2-4964-audit-event-endpoint
JamesDemeryNava Jul 9, 2026
acea576
Address initial feedback: Ensure AuditEvent is returned, fail v3 auth…
JamesDemeryNava Jul 9, 2026
8a2a308
Ensure a AuditEvent/<id> call returns successfully, add .r and .s sco…
JamesDemeryNava Jul 10, 2026
ae7ca66
Merge branch 'master' into jamesdemery/bb2-4964-audit-event-endpoint
JamesDemeryNava Jul 10, 2026
635b3e5
Remove print
JamesDemeryNava Jul 10, 2026
801dc34
Merge branch 'master' into jamesdemery/bb2-4964-audit-event-endpoint
JamesDemeryNava Jul 13, 2026
805b2f1
Address PR feedback
JamesDemeryNava Jul 13, 2026
a82df4f
Add integration test for AuditEvent read
JamesDemeryNava Jul 13, 2026
b84c67e
Changes so that AuditEvent functionality works with the protectedcapa…
JamesDemeryNava Jul 13, 2026
6eb677c
Remove prints
JamesDemeryNava Jul 13, 2026
d777e20
Make comment clearer
JamesDemeryNava Jul 13, 2026
e1f4f1c
Add tests, modify comments
JamesDemeryNava Jul 14, 2026
a35d13e
Address PR feedback
JamesDemeryNava Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/authorization/permissions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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()
Comment on lines +62 to +68

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaning towards leaving this, as it will retrieve the patient_id from the BFD response if it is in the correct path. If it is not, and IndexError may be thrown, which would be caught by the except Exception block, and False would be returned (ultimately resulting in a 403). Otherwise, we can make this change, and it would be a 404 returned instead of a 403 if there was a problem parsing the AuditEvent.

else:
raise exceptions.NotFound()

Expand Down
62 changes: 62 additions & 0 deletions apps/capabilities/management/commands/create_blue_button_scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -320,6 +321,61 @@ def create_token_introspect_capability(group):
return c


def create_audit_event_read_search_capability(group):
c = None
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 = []
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=False,
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 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 = []
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


def create_audit_event_search_capability(group):
c = None
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 = []
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'

Expand All @@ -342,3 +398,9 @@ def handle(self, *args, **options):
create_openid_capability(g)
create_token_management_capability(g)
create_token_introspect_capability(g)

if switch_is_active('enable_auditevents'):
create_audit_event_read_search_capability(g)
create_audit_event_read_capability(g)
create_audit_event_search_capability(g)

7 changes: 7 additions & 0 deletions apps/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@
EOB_SCOPE = 'patient/ExplanationOfBenefit.rs'
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'
AUDIT_EVENT_SCOPE_SET = {AUDIT_EVENT_SCOPE, AUDIT_EVENT_READ_SCOPE, AUDIT_EVENT_SEARCH_SCOPE}

OPERATION_OUTCOME = 'OperationOutcome'

Expand Down Expand Up @@ -146,3 +150,6 @@
FHIR_RES_TYPE_EOB = 'ExplanationOfBenefit'
FHIR_RES_TYPE_PATIENT = 'Patient'
FHIR_RES_TYPE_COVERAGE = 'Coverage'
FHIR_RES_TYPE_AUDIT_EVENT = 'AuditEvent'

SAMPLE_CAN_USER_FHIR_ID_V3 = '-502120048'
2 changes: 2 additions & 0 deletions apps/dot_ext/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1648,3 +1648,5 @@

ID_ME_URL_CONTAINS = 'oidc'
SECONDS_IN_ONE_HOUR = 3600

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/'
24 changes: 23 additions & 1 deletion apps/dot_ext/oauth2_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -104,3 +104,25 @@ 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)

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
return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs)
91 changes: 91 additions & 0 deletions apps/dot_ext/tests/test_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,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
Expand Down Expand Up @@ -1885,3 +1887,92 @@ def test_fail_when_app_only_allowed_client_credentials(self):
),
},
)


@pytest.mark.parametrize(
'scope, auth_url, enable_auditevents_switch_active, expected_message',
[
(
'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, 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
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'

# 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(auth_url), data=payload)

assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()['message'] == expected_message
8 changes: 8 additions & 0 deletions apps/dot_ext/tests/test_authorization_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -477,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
):
Expand Down Expand Up @@ -563,6 +565,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'],
Expand Down
50 changes: 47 additions & 3 deletions apps/dot_ext/tests/test_oauth2_validators.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading
Loading