Skip to content

Commit 99115f2

Browse files
Ryan morosa/bb2 4782 respect v3 app setting calls (#1596)
* Get it initially working * Do some cleanup * Get other test placeholders in place * fix more repo typos (#1585) * fix typo in constant * fix typo in css class and feature switch description * fix typo in acronym SAMHSA is the correct spelling * partial revert "fix typo in css class and feature switch description" Fixing the typo in the CSS class seems to add a box that looks out of place on the signup page, so reverting for now. This partially reverts commit e5fe41a. * BB2-4780 block v1/v2 EOB calls for tokens that suppress SAMSHA data (#1594) Blocks EOB calls on v1/v2 for tokens where the user has gone through a v3 auth flow and elected not to share SAMSHA data. * add permission that blocks EOB requests in v1/2 for tokens that suppress SAMSHA data * fix typo (SAMHSA is the correct spelling) * add tests for SamhsaPermission * remove commented-out test I believe this will be covered in future work. * remove TODO comment * mark tests as integration tests * remove integration tag This test fails before any requests to BFD happen, so it does not need all the setup of an integration test. * change to absolute import * use request.auth, which is documented _auth does not, at least that I could find * rename class to hopefully make meaning clearer * rename class We generally assume v2 means 1 or 2. * Get rid of comment * Get rid of changes in generic * More cleanup * Get rid of ruff errors * Get unit tests working * Get integration tests to better place * Get unit test working better * Make function has_protected_resource a lot nicer looking * Small cleanup * Fix unit tests * Adjust var names * Fix unit test class and function names * Make other integration tests * Get rid of edit to test_integration_fhir * Update constants * Make edit to error fix * Add override switch for v3 endpoints back --------- Co-authored-by: annamontare-nava <267455234+annamontare-nava@users.noreply.github.com>
1 parent b2aa83e commit 99115f2

11 files changed

Lines changed: 329 additions & 64 deletions

File tree

apps/capabilities/permissions.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import json
2-
import re
3-
4-
from apps.dot_ext.scopes import CapabilitiesScopes
51
from rest_framework import permissions, status
62
from rest_framework.exceptions import APIException, ParseError
73
from waffle import switch_is_active
84

95
from apps.capabilities.models import ProtectedCapability
6+
from apps.dot_ext.scopes import CapabilitiesScopes
7+
from apps.utils import has_matching_protected_resource
108

119

1210
class BBCapabilitiesPermissionTokenScopeMissingException(APIException):
@@ -37,20 +35,15 @@ def has_permission(self, request, view) -> bool: # type: ignore
3735
if 'coverage-eligibility' in request.auth.application.get_internal_application_labels():
3836
token_scopes = CapabilitiesScopes().remove_eob_scopes(token_scopes)
3937

40-
scopes = list(
41-
ProtectedCapability.objects.filter(slug__in=token_scopes).values_list('protected_resources', flat=True).all()
38+
protected_resources = list(
39+
ProtectedCapability.objects.filter(slug__in=token_scopes)
40+
.values_list('protected_resources', flat=True)
41+
.all()
4242
)
4343

44-
for scope in scopes:
45-
for method, path in json.loads(scope):
46-
if method != request.method:
47-
continue
48-
if path == request.path:
49-
return True
50-
if re.fullmatch(path, request.path) is not None:
51-
return True
44+
has_match = has_matching_protected_resource(protected_resources, request)
45+
return has_match
5246

53-
return False
5447
else:
5548
# BB2-237: Replaces ASSERT with exception. We should never reach here.
5649
mesg = (

apps/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
' the support team for the application you are trying to access.'
2929
)
3030

31+
# Message output when an app attempts a v3 call but they don't have the correct scopes
32+
APPLICATION_DOES_NOT_HAVE_VALID_SCOPES = (
33+
'This application, {}, does not have access to make calls for {} resources.'
34+
' If you are the app maintainer, please contact the Blue Button API team.'
35+
' If you are a Medicare Beneficiary and need assistance, please contact'
36+
' the support team for the application you are trying to access.'
37+
)
38+
3139
C4BB_PROFILE_URLS = {
3240
'COVERAGE': 'http://hl7.org/fhir/us/carin-bb/StructureDefinition/C4BB-Coverage',
3341
'PATIENT': 'http://hl7.org/fhir/us/carin-bb/StructureDefinition/C4BB-Patient',

apps/fhir/bluebutton/permissions.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import logging
22

33
from django.contrib.auth import get_user_model
4-
from oauth2_provider.views.base import get_access_token_model
54
from oauth2_provider.models import get_application_model
6-
from rest_framework import permissions, exceptions
5+
from oauth2_provider.views.base import get_access_token_model
6+
from rest_framework import exceptions, permissions
77
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
8+
from rest_framework.request import Request
89
from waffle import get_waffle_flag_model
9-
from apps.constants import APPLICATION_TEMPORARILY_INACTIVE, APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET, FHIR_RES_TYPE_EOB
10-
from apps.fhir.constants import ALLOWED_RESOURCE_TYPES
11-
from apps.versions import Versions, VersionNotMatched
1210

13-
from apps.constants import HHS_SERVER_LOGNAME_FMT
11+
from apps.capabilities.models import ProtectedCapability
12+
from apps.constants import (
13+
APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET,
14+
APPLICATION_DOES_NOT_HAVE_VALID_SCOPES,
15+
APPLICATION_TEMPORARILY_INACTIVE,
16+
FHIR_RES_TYPE_EOB,
17+
HHS_SERVER_LOGNAME_FMT,
18+
)
19+
from apps.fhir.constants import ALLOWED_RESOURCE_TYPES
20+
from apps.utils import has_matching_protected_resource
21+
from apps.versions import VersionNotMatched, Versions
1422

1523
logger = logging.getLogger(HHS_SERVER_LOGNAME_FMT.format(__name__))
1624

@@ -113,6 +121,40 @@ def has_permission(self, request, view):
113121
raise PermissionDenied(APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name))
114122

115123

124+
class AppScopePermission(permissions.BasePermission):
125+
def has_permission(self, request: Request, view) -> bool:
126+
"""
127+
Determines if the user/app has permission to make the call it's trying to make. Takes care of the
128+
case where a user/app goes through a v1/v2 auth flow and tries to make a v3 call that it's not authorized to make.
129+
130+
args:
131+
- request: The API Request
132+
- view: The view (search, read, etc.)
133+
returns:
134+
- True if there is a match with the current request and the protected resources the app has in the database.
135+
- Raises a custom 403 Forbidden error if not
136+
"""
137+
# if it is not version 3, we do not need to check the scopes
138+
if view.version < Versions.V3:
139+
return True
140+
141+
token = get_access_token_model().objects.get(token=request._auth)
142+
token_app_id = token.application_id
143+
if not token or not token_app_id:
144+
return False
145+
app_protected_resources = list(
146+
ProtectedCapability.objects.filter(application=token_app_id)
147+
.values_list('protected_resources', flat=True)
148+
.all()
149+
)
150+
has_match = has_matching_protected_resource(app_protected_resources, request)
151+
if not has_match:
152+
raise PermissionDenied(
153+
APPLICATION_DOES_NOT_HAVE_VALID_SCOPES.format(token.application, request.resource_type)
154+
)
155+
return has_match
156+
157+
116158
class V2ExplanationOfBenefitPermission(permissions.BasePermission):
117159
"""
118160
Global permission check that the request is either:

apps/fhir/bluebutton/tests/test_fhir_resources_read_search_w_validation.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
from oauth2_provider.models import get_access_token_model
99
from waffle.testutils import override_switch
1010

11-
from apps.constants import C4BB_PROFILE_URLS, DEFAULT_SAMPLE_FHIR_ID_V2, DEFAULT_SAMPLE_FHIR_ID_V3
12-
from apps.dot_ext.models import AccessTokenExtension
11+
from apps.constants import (
12+
APPLICATION_DOES_NOT_HAVE_VALID_SCOPES,
13+
C4BB_PROFILE_URLS,
14+
COVERAGE_SCOPE,
15+
DEFAULT_SAMPLE_FHIR_ID_V2,
16+
DEFAULT_SAMPLE_FHIR_ID_V3,
17+
EOB_SCOPE,
18+
PATIENT_SCOPE,
19+
)
20+
from apps.dot_ext.models import AccessTokenExtension, Application, ProtectedCapability
1321
from apps.fhir.constants import (
1422
BAD_PARAMS_ACCEPTABLE_VERSIONS,
1523
C4BB_SYSTEM_TYPES,
@@ -604,18 +612,21 @@ def test_patient_request_when_thrown_when_invalid_parameters_included_v1_and_v2(
604612
url = SEARCH_PATIENT_URLS[version]
605613
self._test_request_when_invalid_parameters_included(url, version, HTTPStatus.OK, ENFORCE_PARAM_VALIDATION)
606614

615+
@tag('integration')
607616
def test_eob_request_when_thrown_when_invalid_parameters_and_prefer_strict_header_included_v3(self) -> None:
608617
url = SEARCH_EOB_URLS[Versions.V3]
609618
self._test_request_when_invalid_parameters_included(
610619
url, Versions.V3, HTTPStatus.BAD_REQUEST, ENFORCE_PARAM_VALIDATION
611620
)
612621

622+
@tag('integration')
613623
def test_coverage_request_when_thrown_when_invalid_parameters_and_prefer_strict_header_included_v3(self) -> None:
614624
url = SEARCH_COVERAGE_URLS[Versions.V3]
615625
self._test_request_when_invalid_parameters_included(
616626
url, Versions.V3, HTTPStatus.BAD_REQUEST, ENFORCE_PARAM_VALIDATION
617627
)
618628

629+
@tag('integration')
619630
def test_patient_request_when_invalid_parameters_and_prefer_strict_header_included_v3(self) -> None:
620631
url = SEARCH_PATIENT_URLS[Versions.V3]
621632
self._test_request_when_invalid_parameters_included(
@@ -742,6 +753,7 @@ def test_v12_no_extension_succeeds(self):
742753
response = self.client.get(reverse(SEARCH_EOB_URLS[version]), Authorization=f'Bearer {ac}')
743754
self.assertEqual(response.status_code, 200)
744755

756+
@tag('integration')
745757
def test_v12_include_samhsa_false_fails(self):
746758
"""
747759
Ensure that a v1/2 call for a token with AccessTokenExtension.include_samhsa==False fails
@@ -773,3 +785,102 @@ def test_v12_include_samhsa_true_succeeds(self):
773785
for version in [Versions.V1, Versions.V2]:
774786
response = self.client.get(reverse(SEARCH_EOB_URLS[version]), Authorization=f'Bearer {ac}')
775787
self.assertEqual(response.status_code, 200)
788+
789+
@tag('integration')
790+
@override_switch('v3_endpoints', active=True)
791+
def test_matching_patient_protected_resource_returns_200(self):
792+
"""
793+
Returns a 200 since the patient resource is for a patient and they are trying to make a patient call.
794+
"""
795+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
796+
797+
response = self.client.get(
798+
reverse(SEARCH_PATIENT_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}'
799+
)
800+
self.assertEqual(response.status_code, 200)
801+
802+
@tag('integration')
803+
@override_switch('v3_endpoints', active=True)
804+
def test_matching_coverage_protected_resource_returns_200(self):
805+
"""
806+
Returns a 200 since the coverage resource is for coverage and they are trying to make a coverage call.
807+
"""
808+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
809+
810+
response = self.client.get(
811+
reverse(SEARCH_COVERAGE_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}'
812+
)
813+
self.assertEqual(response.status_code, 200)
814+
815+
@tag('integration')
816+
@override_switch('v3_endpoints', active=True)
817+
def test_matching_eob_protected_resource_returns_200(self):
818+
"""
819+
Returns a 200 since the eob resource is for eob and they are trying to make a eob call.
820+
"""
821+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
822+
823+
response = self.client.get(reverse(SEARCH_EOB_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}')
824+
self.assertEqual(response.status_code, 200)
825+
826+
@tag('integration')
827+
@override_switch('v3_endpoints', active=True)
828+
def test_no_matching_patient_protected_resource_returns_403(self):
829+
"""
830+
Returns a 403 since the patient resource is removed from the database and they are trying to make a patient call.
831+
"""
832+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
833+
834+
# Remove the patient scope for that app and try to make a call
835+
application = Application.objects.get(name='John_Smith_test')
836+
patient_protected_resource = ProtectedCapability.objects.get(title=PATIENT_SCOPE)
837+
application.scope.remove(patient_protected_resource)
838+
839+
response = self.client.get(
840+
reverse(SEARCH_PATIENT_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}'
841+
)
842+
self.assertEqual(response.status_code, 403)
843+
self.assertEqual(
844+
response.json()['detail'], APPLICATION_DOES_NOT_HAVE_VALID_SCOPES.format('John_Smith_test', 'Patient')
845+
)
846+
847+
@tag('integration')
848+
@override_switch('v3_endpoints', active=True)
849+
def test_no_matching_coverage_protected_resource_returns_403(self):
850+
"""
851+
Returns a 403 since the coverage resource is removed from the database and they are trying to make a coverage call.
852+
"""
853+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
854+
855+
# Remove the coverage scope for that app and try to make a call
856+
application = Application.objects.get(name='John_Smith_test')
857+
coverage_protected_resource = ProtectedCapability.objects.get(title=COVERAGE_SCOPE)
858+
application.scope.remove(coverage_protected_resource)
859+
860+
response = self.client.get(
861+
reverse(SEARCH_COVERAGE_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}'
862+
)
863+
self.assertEqual(response.status_code, 403)
864+
self.assertEqual(
865+
response.json()['detail'], APPLICATION_DOES_NOT_HAVE_VALID_SCOPES.format('John_Smith_test', 'Coverage')
866+
)
867+
868+
@tag('integration')
869+
@override_switch('v3_endpoints', active=True)
870+
def test_no_matching_eob_protected_resource_returns_403(self):
871+
"""
872+
Returns a 403 since the eob resource is removed from the database and they are trying to make an eob call.
873+
"""
874+
first_access_token = self.create_token('John', 'Smith', fhir_id_v2=DEFAULT_SAMPLE_FHIR_ID_V2)
875+
876+
# Remove the eob scope for that app and try to make a call
877+
application = Application.objects.get(name='John_Smith_test')
878+
eob_protected_resource = ProtectedCapability.objects.get(title=EOB_SCOPE)
879+
application.scope.remove(eob_protected_resource)
880+
881+
response = self.client.get(reverse(SEARCH_EOB_URLS[Versions.V3]), Authorization=f'Bearer {first_access_token}')
882+
self.assertEqual(response.status_code, 403)
883+
self.assertEqual(
884+
response.json()['detail'],
885+
APPLICATION_DOES_NOT_HAVE_VALID_SCOPES.format('John_Smith_test', 'ExplanationOfBenefit'),
886+
)

0 commit comments

Comments
 (0)