Skip to content

Commit b6388b8

Browse files
authored
Merge pull request #308 from NHSDigital/DTOSS-10523-permission-rules
[DTOSS-10523] define some permissions to distinguish the different roles
2 parents b270bc5 + 9c5c0b8 commit b6388b8

29 files changed

Lines changed: 404 additions & 122 deletions

manage_breast_screening/auth/demo_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ def persona_login(request):
1111
users = _get_users(request.user)
1212

1313
if request.method == "POST":
14-
username = get_object_or_404(users, username=request.POST["username"])
15-
login(request, username)
14+
user = get_object_or_404(users, username=request.POST["username"])
15+
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
1616
return redirect(_next_page(request))
1717

1818
else:

manage_breast_screening/auth/models.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
from dataclasses import dataclass
2+
from enum import StrEnum
3+
4+
5+
class Role(StrEnum):
6+
ADMINISTRATIVE = "Administrative"
7+
CLINICAL = "Clinical"
8+
SUPERUSER = "Superuser"
9+
10+
11+
class Permission(StrEnum):
12+
VIEW_PARTICIPANT_DATA = "participants.view_participant_data"
13+
PERFORM_MAMMOGRAM_APPOINTMENT = "mammograms.perform_mammogram_appointment"
214

315

416
@dataclass
@@ -12,7 +24,7 @@ def username(self):
1224
return f"{self.first_name.lower()}_{self.last_name.lower()}"
1325

1426

15-
ADMINISTRATIVE_PERSONA = Persona("Anna", "Davies", "Administrative")
16-
CLINICAL_PERSONA = Persona("Chloë", "Robinson", "Clinical")
17-
SUPERUSER_PERSONA = Persona("Simon", "O'Brien", "Superuser")
27+
ADMINISTRATIVE_PERSONA = Persona("Anna", "Davies", Role.ADMINISTRATIVE)
28+
CLINICAL_PERSONA = Persona("Chloë", "Robinson", Role.CLINICAL)
29+
SUPERUSER_PERSONA = Persona("Simon", "O'Brien", Role.SUPERUSER)
1830
PERSONAS = [ADMINISTRATIVE_PERSONA, CLINICAL_PERSONA, SUPERUSER_PERSONA]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Define rules and permissions for access control.
3+
4+
This file is automatically imported by rules.apps.AutodiscoverRulesConfig
5+
"""
6+
7+
import rules
8+
9+
from .models import Permission, Role
10+
11+
12+
def user_has_any_role(role, *other_roles):
13+
roles = [role, *other_roles, Role.SUPERUSER]
14+
15+
@rules.predicate
16+
def check(user):
17+
# TODO: customise user model to add a cachable role attribute
18+
return any(group.name in roles for group in user.groups.all())
19+
20+
return check
21+
22+
23+
# fmt: off
24+
25+
rules.add_perm(Permission.VIEW_PARTICIPANT_DATA, user_has_any_role(Role.CLINICAL, Role.ADMINISTRATIVE))
26+
rules.add_perm(Permission.PERFORM_MAMMOGRAM_APPOINTMENT, user_has_any_role(Role.CLINICAL))
27+
28+
# fmt: on
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.contrib.auth import get_user_model
2+
from django.contrib.auth.models import Group
3+
from factory.declarations import Sequence
4+
from factory.django import DjangoModelFactory
5+
from factory.helpers import post_generation
6+
7+
from manage_breast_screening.auth.rules import Role
8+
9+
10+
class UserFactory(DjangoModelFactory):
11+
class Meta:
12+
model = get_user_model()
13+
django_get_or_create = ("first_name", "last_name")
14+
skip_postgeneration_save = True
15+
16+
username = Sequence(lambda n: "alice%d" % n)
17+
first_name = Sequence(lambda n: "alice%d" % n)
18+
last_name = "Lastname"
19+
20+
@post_generation
21+
def groups(self, create, extracted, **kwargs):
22+
if not create:
23+
return
24+
25+
if extracted:
26+
self.groups.set(extracted)
27+
28+
if kwargs.get("administrative"):
29+
self.groups.add(Group.objects.get(name=Role.ADMINISTRATIVE))
30+
31+
if kwargs.get("clinical"):
32+
self.groups.add(Group.objects.get(name=Role.CLINICAL))
33+
34+
if kwargs.get("superuser"):
35+
self.groups.add(Group.objects.get(name=Role.SUPERUSER))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
3+
from manage_breast_screening.auth.models import Permission
4+
5+
6+
@pytest.mark.django_db
7+
class TestRules:
8+
def test_rule_requiring_clinical_role(
9+
self, user, administrative_user, clinical_user, superuser
10+
):
11+
user.groups.add()
12+
13+
assert not user.has_perm(Permission.PERFORM_MAMMOGRAM_APPOINTMENT)
14+
assert not administrative_user.has_perm(
15+
Permission.PERFORM_MAMMOGRAM_APPOINTMENT
16+
)
17+
assert clinical_user.has_perm(Permission.PERFORM_MAMMOGRAM_APPOINTMENT)
18+
assert superuser.has_perm(Permission.PERFORM_MAMMOGRAM_APPOINTMENT)
19+
20+
def test_rule_requiring_clinical_or_administrative_role(
21+
self, user, administrative_user, clinical_user, superuser
22+
):
23+
user.groups.add()
24+
25+
assert not user.has_perm(Permission.VIEW_PARTICIPANT_DATA)
26+
assert administrative_user.has_perm(Permission.VIEW_PARTICIPANT_DATA)
27+
assert clinical_user.has_perm(Permission.VIEW_PARTICIPANT_DATA)
28+
assert superuser.has_perm(Permission.VIEW_PARTICIPANT_DATA)

manage_breast_screening/config/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def boolean_env(key, default=None):
6767
"manage_breast_screening.notifications",
6868
"manage_breast_screening.participants",
6969
"manage_breast_screening.mammograms",
70+
"rules.apps.AutodiscoverRulesConfig",
7071
]
7172

7273
if getenv("DJANGO_ENV", "production") != "production":
@@ -101,6 +102,11 @@ def boolean_env(key, default=None):
101102
INSTALLED_APPS.append("debug_toolbar")
102103
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
103104

105+
AUTHENTICATION_BACKENDS = (
106+
"rules.permissions.ObjectPermissionBackend",
107+
"django.contrib.auth.backends.ModelBackend",
108+
)
109+
104110
ROOT_URLCONF = "manage_breast_screening.core.urls"
105111

106112
TEMPLATES = [

manage_breast_screening/config/settings_test.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,10 @@
1515
},
1616
}
1717

18-
1918
MIDDLEWARE.remove(
2019
"whitenoise.middleware.WhiteNoiseMiddleware",
2120
)
2221

23-
# Make authentication optional until we've updated the system tests to account
24-
# for different roles
25-
MIDDLEWARE.remove(
26-
"django.contrib.auth.middleware.LoginRequiredMiddleware",
27-
)
28-
2922
if DEBUG:
3023
INSTALLED_APPS.remove("debug_toolbar")
3124
MIDDLEWARE.remove("debug_toolbar.middleware.DebugToolbarMiddleware")
Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,50 @@
11
from unittest import TestCase
22

33
import pytest
4-
from django.contrib.auth import get_user_model
4+
from django.test.client import Client
5+
6+
from manage_breast_screening.auth.tests.factories import UserFactory
57

68
# Show long diffs in failed test output
79
TestCase.maxDiff = None
810

911

1012
@pytest.fixture
1113
def user():
12-
return get_user_model().objects.create_user(username="user1", password="123")
14+
return UserFactory.create(username="user1")
15+
16+
17+
@pytest.fixture
18+
def administrative_user():
19+
return UserFactory.create(username="administrative1", groups__administrative=True)
20+
21+
22+
@pytest.fixture
23+
def clinical_user():
24+
return UserFactory.create(username="clinical1", groups__clinical=True)
25+
26+
27+
@pytest.fixture
28+
def superuser():
29+
return UserFactory.create(username="superuser1", groups__superuser=True)
30+
31+
32+
@pytest.fixture
33+
def clinical_user_client(clinical_user):
34+
client = Client()
35+
client.force_login(clinical_user)
36+
return client
37+
38+
39+
@pytest.fixture
40+
def administrative_user_client(administrative_user):
41+
client = Client()
42+
client.force_login(administrative_user)
43+
return client
1344

1445

1546
@pytest.fixture
16-
def logged_in_client(user, client):
17-
client.force_login(user)
47+
def superuser_client(superuser):
48+
client = Client()
49+
client.force_login(superuser)
1850
return client

manage_breast_screening/mammograms/tests/views/test_appointment_flow.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99

1010
@pytest.mark.django_db
1111
class TestShowAppointment:
12-
def test_redirects_if_in_progress(self, logged_in_client, appointment):
13-
response = logged_in_client.get(
12+
def test_redirects_to_show_screening_if_in_progress(
13+
self, clinical_user_client, appointment
14+
):
15+
response = clinical_user_client.get(
1416
reverse("mammograms:show_appointment", kwargs={"pk": appointment.pk})
1517
)
1618
assertRedirects(
@@ -21,8 +23,16 @@ def test_redirects_if_in_progress(self, logged_in_client, appointment):
2123
),
2224
)
2325

24-
def test_renders_response(self, logged_in_client, completed_appointment):
25-
response = logged_in_client.get(
26+
def test_doesnt_redirect_if_not_permitted(
27+
self, administrative_user_client, appointment
28+
):
29+
response = administrative_user_client.get(
30+
reverse("mammograms:show_appointment", kwargs={"pk": appointment.pk})
31+
)
32+
assert response.status_code == 200
33+
34+
def test_renders_response(self, clinical_user_client, completed_appointment):
35+
response = clinical_user_client.get(
2636
reverse(
2737
"mammograms:show_appointment", kwargs={"pk": completed_appointment.pk}
2838
)
@@ -32,8 +42,8 @@ def test_renders_response(self, logged_in_client, completed_appointment):
3242

3343
@pytest.mark.django_db
3444
class TestStartScreening:
35-
def test_appointment_continued(self, logged_in_client, appointment):
36-
response = logged_in_client.post(
45+
def test_appointment_continued(self, clinical_user_client, appointment):
46+
response = clinical_user_client.post(
3747
reverse("mammograms:start_screening", kwargs={"pk": appointment.pk}),
3848
{"decision": "continue"},
3949
)
@@ -45,8 +55,8 @@ def test_appointment_continued(self, logged_in_client, appointment):
4555
),
4656
)
4757

48-
def test_appointment_stopped(self, logged_in_client, appointment):
49-
response = logged_in_client.post(
58+
def test_appointment_stopped(self, clinical_user_client, appointment):
59+
response = clinical_user_client.post(
5060
reverse("mammograms:start_screening", kwargs={"pk": appointment.pk}),
5161
{"decision": "dropout"},
5262
)
@@ -59,9 +69,9 @@ def test_appointment_stopped(self, logged_in_client, appointment):
5969
)
6070

6171
def test_already_completed_appointment_redirects(
62-
self, logged_in_client, completed_appointment
72+
self, clinical_user_client, completed_appointment
6373
):
64-
response = logged_in_client.get(
74+
response = clinical_user_client.get(
6575
reverse(
6676
"mammograms:start_screening", kwargs={"pk": completed_appointment.pk}
6777
)
@@ -74,8 +84,8 @@ def test_already_completed_appointment_redirects(
7484
),
7585
)
7686

77-
def test_renders_invalid_form(self, logged_in_client, appointment):
78-
response = logged_in_client.post(
87+
def test_renders_invalid_form(self, clinical_user_client, appointment):
88+
response = clinical_user_client.post(
7989
reverse("mammograms:start_screening", kwargs={"pk": appointment.pk}),
8090
{},
8191
)
@@ -84,8 +94,8 @@ def test_renders_invalid_form(self, logged_in_client, appointment):
8494

8595
@pytest.mark.django_db
8696
class TestAskForMedicalInformation:
87-
def test_continue_to_record(self, logged_in_client, appointment):
88-
response = logged_in_client.post(
97+
def test_continue_to_record(self, clinical_user_client, appointment):
98+
response = clinical_user_client.post(
8999
reverse(
90100
"mammograms:ask_for_medical_information",
91101
kwargs={"pk": appointment.pk},
@@ -100,8 +110,8 @@ def test_continue_to_record(self, logged_in_client, appointment):
100110
),
101111
)
102112

103-
def test_continue_to_imaging(self, logged_in_client, appointment):
104-
response = logged_in_client.post(
113+
def test_continue_to_imaging(self, clinical_user_client, appointment):
114+
response = clinical_user_client.post(
105115
reverse(
106116
"mammograms:ask_for_medical_information",
107117
kwargs={"pk": appointment.pk},
@@ -116,8 +126,8 @@ def test_continue_to_imaging(self, logged_in_client, appointment):
116126
),
117127
)
118128

119-
def test_renders_invalid_form(self, logged_in_client, appointment):
120-
response = logged_in_client.post(
129+
def test_renders_invalid_form(self, clinical_user_client, appointment):
130+
response = clinical_user_client.post(
121131
reverse(
122132
"mammograms:ask_for_medical_information",
123133
kwargs={"pk": appointment.pk},
@@ -129,17 +139,17 @@ def test_renders_invalid_form(self, logged_in_client, appointment):
129139

130140
@pytest.mark.django_db
131141
class TestCheckIn:
132-
def test_known_redirect(self, logged_in_client, appointment):
133-
response = logged_in_client.post(
142+
def test_known_redirect(self, clinical_user_client, appointment):
143+
response = clinical_user_client.post(
134144
reverse("mammograms:check_in", kwargs={"pk": appointment.pk})
135145
)
136146
assertRedirects(
137147
response,
138148
reverse("mammograms:start_screening", kwargs={"pk": appointment.pk}),
139149
)
140150

141-
def test_audit(self, logged_in_client, appointment):
142-
logged_in_client.post(
151+
def test_audit(self, clinical_user_client, appointment):
152+
clinical_user_client.post(
143153
reverse("mammograms:check_in", kwargs={"pk": appointment.pk})
144154
)
145155
assert (
@@ -153,8 +163,8 @@ def test_audit(self, logged_in_client, appointment):
153163

154164
@pytest.mark.django_db
155165
class TestAppointmentCannotGoAhead:
156-
def test_audit(self, logged_in_client, appointment):
157-
logged_in_client.post(
166+
def test_audit(self, clinical_user_client, appointment):
167+
clinical_user_client.post(
158168
reverse(
159169
"mammograms:appointment_cannot_go_ahead", kwargs={"pk": appointment.pk}
160170
),

0 commit comments

Comments
 (0)