Skip to content

Commit aa2da6c

Browse files
BB2-4964/4965: Audit Event Endpoint/Scope (#1647)
* BB2-4694: Add support for the v3/fhir/AuditEvent endpoint * Add AuditEvent handling to is_resource_for_patient - avoid 404s * 4695 piece of this, add protected capability and scopes for AuditEvent * Switch to default to True for new AuditEvent capability * Modify unit tests to cover new functionality and add integration tests * Throw 400 on v2 auth request if AuditEvent scope included, add/modify tests * Modify tests and basic_user fixture * Add an application fixture, modify tests, add docstrings * Requirement updates * Clean up doc strings * Changes per Copilot PR feedback * Incorporate changes from 4963 being merged * Address initial feedback: Ensure AuditEvent is returned, fail v3 auth if AuditEvent in scope * Ensure a AuditEvent/<id> call returns successfully, add .r and .s scopes. Modify scope param handling for CAN requests * Remove print * Address PR feedback * Add integration test for AuditEvent read * Changes so that AuditEvent functionality works with the protectedcapability records having default = False * Remove prints * Make comment clearer * Add tests, modify comments * Address PR feedback
1 parent a0dd36f commit aa2da6c

23 files changed

Lines changed: 1057 additions & 105 deletions

apps/authorization/permissions.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from rest_framework import permissions, exceptions
2-
from apps.constants import APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG
3-
from apps.versions import Versions, VersionNotMatched
1+
from rest_framework import exceptions, permissions
42

53
from apps.authorization.models import DataAccessGrant
4+
from apps.constants import APPLICATION_THIRTEEN_MONTH_DATA_ACCESS_EXPIRED_MESG
5+
from apps.versions import VersionNotMatched, Versions
66

77

88
class DataAccessGrantPermission(permissions.BasePermission):
@@ -59,6 +59,13 @@ def is_resource_for_patient(obj, patient_id):
5959
elif obj['resourceType'] == 'Bundle':
6060
for entry in obj.get('entry', []):
6161
is_resource_for_patient(entry['resource'], patient_id)
62+
elif obj['resourceType'] == 'AuditEvent':
63+
entity = obj.get('entity', [{}])
64+
patient_info = entity[0].get('what', {})
65+
reference = patient_info.get('reference', '')
66+
reference_id = reference.split('/')[1]
67+
if reference_id != patient_id:
68+
raise exceptions.NotFound()
6269
else:
6370
raise exceptions.NotFound()
6471

apps/capabilities/management/commands/create_blue_button_scopes.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib.auth.models import Group
55
from django.urls import reverse
66
from django.core.management.base import BaseCommand
7+
from waffle import switch_is_active
78
from apps.capabilities.constants import FHIR_PREFIX_CREATE_BLUE_BUTTON_SCOPES
89
from apps.capabilities.models import ProtectedCapability
910

@@ -320,6 +321,61 @@ def create_token_introspect_capability(group):
320321
return c
321322

322323

324+
def create_audit_event_read_search_capability(group):
325+
c = None
326+
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).'
327+
title = 'Audit Event FHIR Resource Read/Search'
328+
smart_scope_string = 'patient/AuditEvent.rs'
329+
protected_resources = []
330+
protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/]?$'])
331+
protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/?].*$'])
332+
333+
if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists():
334+
c = ProtectedCapability.objects.create(group=group,
335+
title=title,
336+
description=description,
337+
default=False,
338+
slug=smart_scope_string,
339+
protected_resources=json.dumps(protected_resources, indent=4))
340+
return c
341+
342+
343+
def create_audit_event_read_capability(group):
344+
c = None
345+
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).'
346+
title = 'Audit Event FHIR Resource Read'
347+
smart_scope_string = 'patient/AuditEvent.r'
348+
protected_resources = []
349+
protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/?].*$'])
350+
351+
if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists():
352+
c = ProtectedCapability.objects.create(group=group,
353+
title=title,
354+
description=description,
355+
default=False,
356+
slug=smart_scope_string,
357+
protected_resources=json.dumps(protected_resources, indent=4))
358+
return c
359+
360+
361+
def create_audit_event_search_capability(group):
362+
c = None
363+
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).'
364+
title = 'Audit Event FHIR Resource Search'
365+
smart_scope_string = 'patient/AuditEvent.s'
366+
protected_resources = []
367+
protected_resources.append(['GET', '/v[3]/fhir/AuditEvent[/]?$'])
368+
369+
if not ProtectedCapability.objects.filter(slug=smart_scope_string).exists():
370+
c = ProtectedCapability.objects.create(group=group,
371+
title=title,
372+
description=description,
373+
default=False,
374+
slug=smart_scope_string,
375+
protected_resources=json.dumps(protected_resources, indent=4))
376+
return c
377+
378+
323379
class Command(BaseCommand):
324380
help = 'Create BlueButton Group and Scopes'
325381

@@ -342,3 +398,9 @@ def handle(self, *args, **options):
342398
create_openid_capability(g)
343399
create_token_management_capability(g)
344400
create_token_introspect_capability(g)
401+
402+
if switch_is_active('enable_auditevents'):
403+
create_audit_event_read_search_capability(g)
404+
create_audit_event_read_capability(g)
405+
create_audit_event_search_capability(g)
406+

apps/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@
113113
EOB_SCOPE = 'patient/ExplanationOfBenefit.rs'
114114
OPENID_SCOPE = 'openid'
115115
LAUNCH_SCOPE = 'launch/patient'
116+
AUDIT_EVENT_SCOPE = 'patient/AuditEvent.rs'
117+
AUDIT_EVENT_READ_SCOPE = 'patient/AuditEvent.r'
118+
AUDIT_EVENT_SEARCH_SCOPE = 'patient/AuditEvent.s'
119+
AUDIT_EVENT_SCOPE_SET = {AUDIT_EVENT_SCOPE, AUDIT_EVENT_READ_SCOPE, AUDIT_EVENT_SEARCH_SCOPE}
116120

117121
OPERATION_OUTCOME = 'OperationOutcome'
118122

@@ -146,3 +150,6 @@
146150
FHIR_RES_TYPE_EOB = 'ExplanationOfBenefit'
147151
FHIR_RES_TYPE_PATIENT = 'Patient'
148152
FHIR_RES_TYPE_COVERAGE = 'Coverage'
153+
FHIR_RES_TYPE_AUDIT_EVENT = 'AuditEvent'
154+
155+
SAMPLE_CAN_USER_FHIR_ID_V3 = '-502120048'

apps/dot_ext/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,3 +1648,5 @@
16481648

16491649
ID_ME_URL_CONTAINS = 'oidc'
16501650
SECONDS_IN_ONE_HOUR = 3600
1651+
1652+
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/'

apps/dot_ext/oauth2_validators.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from oauthlib.oauth2.rfc6749 import utils
77
from oauthlib.oauth2.rfc6749.errors import InvalidGrantError
88

9-
from apps.constants import CLIENT_CREDENTIALS
9+
from apps.constants import AUDIT_EVENT_SCOPE_SET, CLIENT_CREDENTIALS
1010
from apps.dot_ext.scopes import CapabilitiesScopes
1111
from apps.pkce.oauth2_validators import PKCEValidatorMixin
1212

@@ -104,3 +104,25 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs):
104104
return super().get_original_scopes(refresh_token, request, *args, **kwargs)
105105
except ObjectDoesNotExist:
106106
raise InvalidGrantError
107+
108+
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
109+
# If the grant_type is 'client_credentials', execute some custom scopes handling
110+
if request.grant_type == CLIENT_CREDENTIALS:
111+
# Grab a list of the scopes that do not include any AuditEvent scopes. If any scopes
112+
# are in the resulting list, we will pass those scopes to the OAuth2Validator.validate_scopes
113+
# to be checked as scopes normally are
114+
scopes_with_audit_event_filtered_out = list(set(scopes) - AUDIT_EVENT_SCOPE_SET)
115+
116+
if scopes_with_audit_event_filtered_out:
117+
return super().validate_scopes(
118+
client_id, scopes_with_audit_event_filtered_out, client, request, *args, **kwargs
119+
)
120+
121+
# If this code executes, there are no non-AuditEvent scopes in the request. To determine if the requested
122+
# scopes are valid, get the intersection of requested scopes and the AUDIT_EVENT_SCOPE_SET. If there is any
123+
# intersection, this will return True, if not, False.
124+
audit_event_scopes_in_request = set(scopes) & AUDIT_EVENT_SCOPE_SET
125+
return bool(audit_event_scopes_in_request)
126+
127+
# For refresh_token and authorization-code grant types, validate scopes as normal
128+
return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs)

apps/dot_ext/tests/test_authorization.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import MagicMock, patch
77
from urllib.parse import parse_qs, urlencode, urlparse
88

9+
import pytest
910
import pytz
1011
from dateutil.relativedelta import relativedelta
1112
from django.db.models import Q
@@ -22,6 +23,7 @@
2223
from apps.constants import CODE_CHALLENGE_METHOD_S256, PATIENT_SCOPE
2324
from apps.dot_ext.constants import (
2425
APPLICATION_HAS_CLIENT_CREDENTIALS_ENABLED_NON_CLIENT_CREDENTIALS_AUTH_CALL_MADE,
26+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
2527
CLIENT_CREDENTIALS_TYPE,
2628
)
2729
from apps.dot_ext.models import Application, ArchivedToken
@@ -1885,3 +1887,92 @@ def test_fail_when_app_only_allowed_client_credentials(self):
18851887
),
18861888
},
18871889
)
1890+
1891+
1892+
@pytest.mark.parametrize(
1893+
'scope, auth_url, enable_auditevents_switch_active, expected_message',
1894+
[
1895+
(
1896+
'patient/Patient.rs patient/AuditEvent.rs',
1897+
'oauth2_provider:authorize',
1898+
True,
1899+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1900+
),
1901+
('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', True, AUDIT_EVENT_SCOPE_ERROR_MESSAGE),
1902+
('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', True, AUDIT_EVENT_SCOPE_ERROR_MESSAGE),
1903+
(
1904+
'patient/Patient.rs patient/AuditEvent.rs',
1905+
'oauth2_provider_v2:authorize-v2',
1906+
True,
1907+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1908+
),
1909+
(
1910+
'patient/Patient.rs patient/AuditEvent.s',
1911+
'oauth2_provider_v2:authorize-v2',
1912+
True,
1913+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1914+
),
1915+
(
1916+
'patient/Patient.rs patient/AuditEvent.r',
1917+
'oauth2_provider_v2:authorize-v2',
1918+
True,
1919+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1920+
),
1921+
(
1922+
'patient/Patient.rs patient/AuditEvent.rs',
1923+
'oauth2_provider_v3:authorize-v3',
1924+
True,
1925+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1926+
),
1927+
(
1928+
'patient/Patient.rs patient/AuditEvent.s',
1929+
'oauth2_provider_v3:authorize-v3',
1930+
True,
1931+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1932+
),
1933+
(
1934+
'patient/Patient.rs patient/AuditEvent.r',
1935+
'oauth2_provider_v3:authorize-v3',
1936+
True,
1937+
AUDIT_EVENT_SCOPE_ERROR_MESSAGE,
1938+
),
1939+
('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider:authorize', False, 'Invalid scopes.'),
1940+
('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider:authorize', False, 'Invalid scopes.'),
1941+
('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider:authorize', False, 'Invalid scopes.'),
1942+
('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'),
1943+
('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'),
1944+
('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v2:authorize-v2', False, 'Invalid scopes.'),
1945+
('patient/Patient.rs patient/AuditEvent.rs', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'),
1946+
('patient/Patient.rs patient/AuditEvent.s', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'),
1947+
('patient/Patient.rs patient/AuditEvent.r', 'oauth2_provider_v3:authorize-v3', False, 'Invalid scopes.'),
1948+
],
1949+
)
1950+
@override_switch('v3_endpoints', active=True)
1951+
def test_failure_on_authorize_non_v3_with_audit_event_scope(
1952+
create_application, scope, auth_url, enable_auditevents_switch_active, expected_message
1953+
):
1954+
"""Ensure a bad request 400 error, with message equal to Invalid scopes is raised
1955+
when there is a v1, 2, or 3 auth request that includes any AuditEvent scope in the scopes param
1956+
and regardless of if the enable_auditevents switch is true or false
1957+
"""
1958+
with override_switch('enable_auditevents', active=enable_auditevents_switch_active):
1959+
redirect_uri = 'http://localhost'
1960+
1961+
# create an application via fixture
1962+
application = create_application('an app')
1963+
payload = {
1964+
'client_id': application.client_id,
1965+
'response_type': 'code',
1966+
'redirect_uri': redirect_uri,
1967+
'scope': [scope],
1968+
'expires_in': 86400,
1969+
'allow': True,
1970+
'state': '0123456789abcdef',
1971+
'code_challenge': 'sZrievZsrYqxdnu2NVD603EiYBM18CuzZpwB-pOSZjo',
1972+
'code_challenge_method': CODE_CHALLENGE_METHOD_S256,
1973+
}
1974+
1975+
response = Client().post(reverse(auth_url), data=payload)
1976+
1977+
assert response.status_code == HTTPStatus.BAD_REQUEST
1978+
assert response.json()['message'] == expected_message

apps/dot_ext/tests/test_authorization_token.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from apps.capabilities.models import ProtectedCapability
2020
from apps.constants import (
21+
AUDIT_EVENT_SCOPE,
2122
CLIENT_CREDENTIALS,
2223
CODE_CHALLENGE_METHOD_S256,
2324
REFRESH_TOKEN,
@@ -477,6 +478,7 @@ def setUp(self):
477478
@patch('apps.dot_ext.views.authorization.TokenView._create_or_retrieve_user')
478479
@patch('apps.dot_ext.views.authorization.get_patient_match_response_json')
479480
@override_switch('v3_endpoints', active=True)
481+
@override_switch('enable_auditevents', active=True)
480482
def test_client_credentials_token_and_refresh(
481483
self, mock_get_patient, mock_create_user, mock_validate_ial, mock_validate_auth, mock_get_and_update
482484
):
@@ -563,6 +565,12 @@ def test_client_credentials_token_and_refresh(
563565
self.assertIn('patient/ExplanationOfBenefit.rs', data['scope'])
564566
self.assertIn('refresh_token', data)
565567

568+
# BB2-4965: Even though patient/AuditEvent.rs was not in the requested scopes, it was automatically added
569+
# to the token scope as all client_credentials auth flows should result in a token with patient/AuditEvent.rs
570+
# on it
571+
access_token = get_access_token_model().objects.get(token=data['access_token'])
572+
assert AUDIT_EVENT_SCOPE in access_token.scope
573+
566574
refresh_request_data = {
567575
'grant_type': REFRESH_TOKEN,
568576
'refresh_token': data['refresh_token'],

apps/dot_ext/tests/test_oauth2_validators.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
from unittest.mock import patch
2+
3+
import pytest
14
from django.core.exceptions import ValidationError
25
from django.http import HttpRequest
36
from django.test import TestCase
4-
from unittest.mock import patch
57

6-
from apps.dot_ext.oauth2_validators import OAuth2Validator
8+
from apps.constants import CLIENT_CREDENTIALS, REFRESH_TOKEN, TEST_APP_CLIENT_ID
9+
from apps.dot_ext.constants import V2_SCOPES_ALL
10+
from apps.dot_ext.oauth2_validators import OAuth2Validator, SingleAccessTokenValidator
711
from apps.dot_ext.validators import validate_uris
812

913
PATIENT_COVERAGE_SCOPES = 'patient/Patient.rs patient/Coverage.rs'
@@ -64,7 +68,9 @@ def test_is_within_original_scope_invalid_request_profile(self):
6468
def test_is_within_original_scope_valid_request_profile(self):
6569
validator = OAuth2Validator()
6670
request = HttpRequest()
67-
with patch.object(validator, 'get_original_scopes', return_value='profile patient/Patient.rs patient/Coverage.rs'):
71+
with patch.object(
72+
validator, 'get_original_scopes', return_value='profile patient/Patient.rs patient/Coverage.rs'
73+
):
6874
result = validator.is_within_original_scope(['profile'], object(), request)
6975

7076
assert result
@@ -90,3 +96,41 @@ def test_is_within_original_scope_invalid_request_read_when_access_token_had_sea
9096
result = validator.is_within_original_scope(['patient/ExplanationOfBenefit.r'], object(), request)
9197

9298
assert not result
99+
100+
101+
@pytest.mark.django_db
102+
@pytest.mark.parametrize(
103+
'scopes, grant_type',
104+
[
105+
(['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], CLIENT_CREDENTIALS),
106+
(['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], CLIENT_CREDENTIALS),
107+
(['patient/AuditEvent.rs', 'patient/Patient.rs'], CLIENT_CREDENTIALS),
108+
(['patient/AuditEvent.r'], CLIENT_CREDENTIALS),
109+
(['patient/AuditEvent.rs'], CLIENT_CREDENTIALS),
110+
(['profile', 'patient/Coverage.r'], CLIENT_CREDENTIALS),
111+
(['profile', 'patient/Coverage.r'], CLIENT_CREDENTIALS),
112+
(['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], REFRESH_TOKEN),
113+
(['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], REFRESH_TOKEN),
114+
(['patient/ExplanationOfBenefit.rs', 'patient/Patient.rs'], 'authorization-code'),
115+
(['patient/ExplanationOfBenefit.rs', 'patient/Coverage.rs'], 'authorization-code'),
116+
],
117+
)
118+
def test_validate_scopes(create_application, scopes, grant_type):
119+
"""Ensure that the overwritten validate_scopes function processes our standard scopes (EOB, Coverage, Patient)
120+
and the AuditEvent scopes correctly. AuditEvent scopes have default equal to false and are not added to applications
121+
(as of July 2026), so we have custom handling for those.
122+
123+
Args:
124+
scopes: List of scopes we are validating will go through the overwritten OAuth2 function
125+
successfully
126+
"""
127+
validator = SingleAccessTokenValidator()
128+
with patch.object(OAuth2Validator, 'validate_scopes', return_value=True):
129+
with patch.object(validator, 'get_original_scopes', return_value=V2_SCOPES_ALL):
130+
app = create_application('TestApp')
131+
request = HttpRequest()
132+
request.grant_type = grant_type
133+
client_id = TEST_APP_CLIENT_ID
134+
result = validator.validate_scopes(client_id, scopes, app, request)
135+
136+
assert result

0 commit comments

Comments
 (0)