Skip to content

Commit 6056bd8

Browse files
BLUEBUTTON-1647 Demographic filter scopes phase1 (#788)
* Updates interface to allow bene sharing choice * Add auth template choice by feature switch * Choose authorize template per feature switch * Add scope share_choice to form and template * Remove custom save_bearer_token() OAUTH2 method * Update tests for authorize token behavior * Update scopes.json fixture for local dev. * Refactor block_personal_choice logic in to view * Refactor block_personal_choice logic to form clean() * Update tests for change of scope and new AC * Fix test for non require-scopes switch * Add SimpleAllowForm form tests * Move BENE_PERSONAL_INFO_SCOPES to base settings * Add group to scopes.json fixutre * Update form test to use scopes.json fixture * Update view test for block_personal_choice * Update migrate to load scopes.json for local dev Co-authored-by: John French <hello@johnfrench.xyz>
1 parent fc761d7 commit 6056bd8

File tree

14 files changed

+271
-152
lines changed

14 files changed

+271
-152
lines changed

apps/accounts/fixtures/scopes.json

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,71 @@
11
[
2+
{
3+
"model": "auth.group",
4+
"pk": 5,
5+
"fields": {
6+
"name": "BlueButton",
7+
"permissions": []
8+
}
9+
},
210
{
311
"model": "capabilities.protectedcapability",
412
"pk": 1,
513
"fields": {
6-
"title": "Beneficiary Blue Button Patient Resource",
14+
"title": "My general patient and demographic information.",
715
"slug": "patient/Patient.read",
8-
"group": 1,
9-
"description": "This capability allows a 3rd party application to access beneficiary data when authorized by the beneficiary.",
10-
"protected_resources": "[[\"GET\", \"/v1/fhir/Patient/[id]\"]]"
16+
"group": 5,
17+
"description": "Patient FHIR Resource",
18+
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/Patient[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/Patient[/?].*$\"\n ]\n]",
19+
"default": "True"
1120
}
1221
},
1322
{
1423
"model": "capabilities.protectedcapability",
1524
"pk": 2,
1625
"fields": {
17-
"title": "OpenID Connect Profile",
26+
"title": "Profile information including name and email.",
1827
"slug": "profile",
19-
"group": 1,
20-
"description": "Get the Open ID profile.",
21-
"protected_resources": "[[\"GET\", \"/connect/userinfo\"]]"
28+
"group": 5,
29+
"description": "OIDC userinfo endpoint /connect/userinfo",
30+
"protected_resources": "[\n [\n \"GET\",\n \"/v1/connect/userinfo.*$\"\n ]\n]",
31+
"default": "True"
2232
}
2333
},
2434
{
2535
"model": "capabilities.protectedcapability",
2636
"pk": 3,
2737
"fields": {
28-
"title": "Beneficiary Blue Button ExplanationOfBenefit Resource",
38+
"title": "My Medicare claim information.",
2939
"slug": "patient/ExplanationOfBenefit.read",
30-
"group": 1,
31-
"description": "[[\"GET\", \"/v1/fhir/ExplanationOfBenefit/[id]\"]]",
32-
"protected_resources": "[[\"GET\", \"/v1/fhir/ExplanationOfBenefit/[id]\"]]"
40+
"group": 5,
41+
"description": "ExplanationOfBenefit FHIR Resource",
42+
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/ExplanationOfBenefit[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/ExplanationOfBenefit[/?].*$\"\n ]\n]",
43+
"default": "True"
3344
}
3445
},
3546
{
3647
"model": "capabilities.protectedcapability",
3748
"pk": 4,
3849
"fields": {
39-
"title": "OpenIDConnect",
40-
"slug": "openid",
41-
"group": 1,
42-
"description": "Just a declaration.",
43-
"protected_resources": "[]"
50+
"title": "My Medicare and supplemental coverage information.",
51+
"slug": "patient/Coverage.read",
52+
"group": 5,
53+
"description": "Coverage FHIR Resource",
54+
"protected_resources": "[\n [\n \"GET\",\n \"/v1/fhir/Coverage[/]?$\"\n ],\n [\n \"GET\",\n \"/v1/fhir/Coverage[/?].*$\"\n ]\n]",
55+
"default": "True"
4456
}
4557
},
4658
{
4759
"model": "capabilities.protectedcapability",
4860
"pk": 5,
4961
"fields": {
50-
"title": "Beneficiary Blue Button Organization Resource",
51-
"slug": "patient/Organization.read",
52-
"group": 1,
53-
"description": "Read FHIR Organization Resource",
54-
"protected_resources": "[[\"GET\", \"/bluebutton/fhir/v1/Organization/[id]\"]]"
55-
}
56-
},
57-
{
58-
"model": "capabilities.protectedcapability",
59-
"pk": 6,
60-
"fields": {
61-
"title": "Beneficiary Blue Button Coverage Resource",
62-
"slug": "patient/Coverage.read",
63-
"group": 1,
64-
"description": "FHIR Coverage Resource (Read Only)",
65-
"protected_resources": "[[\"GET\", \"/bluebutton/fhir/v1/Coverage/[id]\"]]"
62+
"title": "Token Management",
63+
"slug": "token_management",
64+
"group": 5,
65+
"description": "Allow an app to manage all of a user's tokens.",
66+
"protected_resources": "[]",
67+
"protected_resources": "[[\"GET\", \"/some-url\"]]",
68+
"default": "False"
6669
}
6770
}
6871
]

apps/accounts/tests/test_api.py

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -96,43 +96,11 @@ def test_single_access_token_issued(self):
9696
second_access_token = self._get_access_token('john',
9797
'123456',
9898
application)
99-
self.assertEqual(first_access_token, second_access_token)
100-
101-
def test_single_access_token_issued_when_changed_scope_allowed(self):
102-
"""
103-
Test that the same access token is issued when a scope is changed but
104-
it is a subset of the old token's scope.
105-
106-
e.g. old_token_scope = 'read write'
107-
new_token_scope = 'read'
108-
"""
109-
# create the user
110-
self._create_user('john',
111-
'123456',
112-
first_name='John',
113-
last_name='Smith',
114-
email='john@smith.net')
115-
# create read and write capabilities
116-
read_capability = self._create_capability('Read', [])
117-
write_capability = self._create_capability('Write', [])
118-
# create a oauth2 application and add capabilities
119-
application = self._create_application('test')
120-
application.scope.add(read_capability, write_capability)
121-
# get the first access token for the user 'john'
122-
first_access_token = self._get_access_token('john',
123-
'123456',
124-
application,
125-
scope='read write')
126-
# request another access token for the same user/application
127-
second_access_token = self._get_access_token('john',
128-
'123456',
129-
application,
130-
scope='read')
131-
self.assertEqual(first_access_token, second_access_token)
99+
self.assertNotEqual(first_access_token, second_access_token)
132100

133-
def test_new_access_token_issued_when_scope_added(self):
101+
def test_new_access_token_issued_when_scope_changed(self):
134102
"""
135-
Test that a new access token is issued when a scope is added.
103+
Test that a new access token is issued when a scope is changed.
136104
137105
e.g. old_token_scope = 'read'
138106
new_token_scope = 'read write'

apps/core/management/commands/create_test_feature_switches.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
WAFFLE_FEATURE_SWITCHES = (('outreach_email', True),
66
('wellknown_applications', True),
77
('login', True),
8-
('signup', True))
8+
('signup', True),
9+
('require-scopes', True))
910

1011

1112
class Command(BaseCommand):

apps/dot_ext/forms.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,15 @@ def save(self, *args, **kwargs):
152152
class SimpleAllowForm(DotAllowForm):
153153
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
154154
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
155+
block_personal_choice = forms.BooleanField(required=False)
156+
157+
def clean(self):
158+
cleaned_data = super().clean()
159+
scope = cleaned_data.get("scope")
160+
161+
# Remove personal information scopes, if requested by bene
162+
if cleaned_data.get("block_personal_choice"):
163+
cleaned_data['scope'] = ' '.join([s for s in scope.split(" ")
164+
if s not in settings.BENE_PERSONAL_INFO_SCOPES])
165+
166+
return cleaned_data

apps/dot_ext/oauth2_validators.py

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
import math
2-
3-
from django.utils import timezone
4-
from django.utils.timezone import timedelta
5-
6-
from oauth2_provider.models import AccessToken, RefreshToken
71
from oauth2_provider.oauth2_validators import OAuth2Validator
82
from django.core.exceptions import ObjectDoesNotExist
93
from apps.pkce.oauth2_validators import PKCEValidatorMixin
@@ -32,66 +26,6 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **k
3226
*args,
3327
**kwargs)
3428

35-
# TODO: remove this
36-
# https://github.com/jazzband/django-oauth-toolkit/blob/f0091f17445e1481692bcebc2fc2d9b5b522b608/oauth2_provider/oauth2_validators.py#L337
37-
def save_bearer_token(self, token, request, *args, **kwargs):
38-
"""
39-
Check if an access_token exists for the couple user/application
40-
that is valid and authorized for the same scopes and ensures that
41-
no refresh token was used.
42-
43-
If all the conditions are true the same access_token is issued.
44-
Otherwise a new one is created with the default strategy.
45-
"""
46-
# this queryset identifies all the valid access tokens
47-
# for the couple user/application.
48-
previous_valid_tokens = AccessToken.objects.filter(
49-
user=request.user, application=request.client,
50-
).filter(expires__gt=timezone.now()).order_by('-expires')
51-
52-
# if a refresh token was not used and a valid token exists we
53-
# can replace the new generated token with the old one.
54-
if not request.refresh_token and previous_valid_tokens.exists():
55-
for access_token in previous_valid_tokens:
56-
# the previous access_token must allow access to the same scope
57-
# or bigger
58-
if access_token.allow_scopes(token['scope'].split()):
59-
token['access_token'] = access_token.token
60-
expires_in = access_token.expires - timezone.now()
61-
token['expires_in'] = math.floor(expires_in.total_seconds())
62-
63-
if hasattr(access_token, 'refresh_token'):
64-
token['refresh_token'] = access_token.refresh_token.token
65-
66-
# break the loop and exist because we found to old token
67-
return
68-
69-
# default behaviour when no old token is found
70-
if request.refresh_token:
71-
# remove used refresh token
72-
RefreshToken.objects.get(token=request.refresh_token).revoke()
73-
74-
expires = timezone.now() + timedelta(seconds=token['expires_in'])
75-
if request.grant_type == 'client_credentials':
76-
request.user = None
77-
78-
access_token = AccessToken(
79-
user=request.user,
80-
scope=token['scope'],
81-
expires=expires,
82-
token=token['access_token'],
83-
application=request.client)
84-
access_token.save()
85-
86-
if 'refresh_token' in token:
87-
refresh_token = RefreshToken(
88-
user=request.user,
89-
token=token['refresh_token'],
90-
application=request.client,
91-
access_token=access_token
92-
)
93-
refresh_token.save()
94-
9529
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
9630
try:
9731
return super().get_original_scopes(refresh_token, request, *args, **kwargs)
File renamed without changes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from apps.dot_ext.forms import SimpleAllowForm
2+
from apps.dot_ext.scopes import CapabilitiesScopes
3+
from apps.test import BaseApiTest
4+
from django.conf import settings
5+
6+
7+
class TestSimpleAllowFormForm(BaseApiTest):
8+
fixtures = ['scopes.json']
9+
10+
def test_form(self):
11+
"""
12+
Test form related to scopes and BENE block_personal_choice.
13+
"""
14+
full_scopes_list = CapabilitiesScopes().get_default_scopes()
15+
non_personal_scopes_list = list(set(full_scopes_list) - set(settings.BENE_PERSONAL_INFO_SCOPES))
16+
17+
data = {'redirect_uri': 'http://localhost:3000/bluebutton/callback/',
18+
'scope': ' '.join(full_scopes_list),
19+
'client_id': 'AAAAAAAAAA1111111111111111AAAAAAAAAAAAAA',
20+
'state': 'ba0a6e3c704ced52c7788331e6bab262',
21+
'response_type': 'code',
22+
'code_challenge': '',
23+
'code_challenge_method': '',
24+
'allow': 'Allow'}
25+
26+
# 1. Test with block_personal_choice = False
27+
# Should have full scopes list.
28+
data['block_personal_choice'] = 'False'
29+
form = SimpleAllowForm(data)
30+
self.assertTrue(form.is_valid())
31+
cleaned_data = form.cleaned_data
32+
33+
self.assertNotEqual(cleaned_data['scope'].split(), None)
34+
self.assertEqual(sorted(full_scopes_list),
35+
sorted(cleaned_data['scope'].split()))
36+
37+
# 2. Test with block_personal_choice = True
38+
# Should have non personal scopes list.
39+
data['block_personal_choice'] = 'True'
40+
form = SimpleAllowForm(data)
41+
self.assertTrue(form.is_valid())
42+
cleaned_data = form.cleaned_data
43+
self.assertNotEqual(cleaned_data['scope'].split(), None)
44+
self.assertEqual(sorted(non_personal_scopes_list),
45+
sorted(cleaned_data['scope'].split()))

0 commit comments

Comments
 (0)