Skip to content

Commit ce7b894

Browse files
authored
Merge branch 'main' into improve_tasks_0409
2 parents b90c37b + a7b6876 commit ce7b894

5 files changed

Lines changed: 473 additions & 48 deletions

File tree

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
"""Folder scoping of the duplicate actions: the target folder must be one the
2+
requesting user can create objects in, exactly as on a regular create."""
3+
4+
import pytest
5+
from rest_framework.test import APIClient
6+
from knox.models import AuthToken
7+
8+
from core.models import (
9+
AppliedControl,
10+
OrganisationObjective,
11+
Perimeter,
12+
RiskAssessment,
13+
RiskMatrix,
14+
)
15+
from iam.models import Folder, User, UserGroup
16+
17+
MATRIX_DEFINITION = {
18+
"type": "risk_matrix",
19+
"name": "3x3",
20+
"description": "",
21+
"probability": [
22+
{"abbreviation": "L", "name": "Low", "description": ""},
23+
{"abbreviation": "M", "name": "Medium", "description": ""},
24+
{"abbreviation": "H", "name": "High", "description": ""},
25+
],
26+
"impact": [
27+
{"abbreviation": "L", "name": "Low", "description": ""},
28+
{"abbreviation": "M", "name": "Medium", "description": ""},
29+
{"abbreviation": "H", "name": "High", "description": ""},
30+
],
31+
"risk": [
32+
{"abbreviation": "L", "name": "Low", "description": "", "hexcolor": "#fff"},
33+
{"abbreviation": "M", "name": "Medium", "description": "", "hexcolor": "#fff"},
34+
{"abbreviation": "H", "name": "High", "description": "", "hexcolor": "#fff"},
35+
],
36+
"grid": [[0, 1, 2], [0, 1, 2], [0, 1, 2]],
37+
}
38+
39+
40+
def _make_domain(name):
41+
folder = Folder.objects.create(
42+
name=name,
43+
content_type=Folder.ContentType.DOMAIN,
44+
parent_folder=Folder.get_root_folder(),
45+
create_iam_groups=True,
46+
)
47+
Folder.create_default_ug_and_ra(folder)
48+
return folder
49+
50+
51+
def _client_for(email, memberships):
52+
"""Authenticated client for a fresh user added to ``(group_name, folder)`` pairs."""
53+
user = User.objects.create_user(email, is_published=True)
54+
for group_name, folder in memberships:
55+
group = UserGroup.objects.get(name=group_name, folder=folder)
56+
group.user_set.add(user)
57+
user.folder = memberships[0][1]
58+
user.save()
59+
client = APIClient()
60+
_, token = AuthToken.objects.create(user=user)
61+
client.credentials(HTTP_AUTHORIZATION=f"Token {token}")
62+
return client
63+
64+
65+
@pytest.fixture
66+
def domains(app_config, db):
67+
return _make_domain("dup-domain-a"), _make_domain("dup-domain-b")
68+
69+
70+
# --- applied controls ---------------------------------------------------------
71+
72+
73+
@pytest.mark.django_db
74+
def test_applied_control_duplicate_same_domain(domains):
75+
domain_a, _ = domains
76+
client = _client_for("dup-ana-a@tests.com", [("BI-UG-ANA", domain_a)])
77+
control = AppliedControl.objects.create(name="ctl", folder=domain_a)
78+
79+
response = client.post(
80+
f"/api/applied-controls/{control.id}/duplicate/",
81+
{
82+
"name": "ctl copy",
83+
"description": "",
84+
"folder": str(domain_a.id),
85+
"duplicate_evidences": False,
86+
},
87+
format="json",
88+
)
89+
90+
assert response.status_code == 200, response.content
91+
assert AppliedControl.objects.filter(name="ctl copy", folder=domain_a).exists()
92+
93+
94+
@pytest.mark.django_db
95+
def test_applied_control_duplicate_requires_rights_on_target_domain(domains):
96+
domain_a, domain_b = domains
97+
client = _client_for("dup-ana-a@tests.com", [("BI-UG-ANA", domain_a)])
98+
control = AppliedControl.objects.create(name="ctl", folder=domain_a)
99+
100+
response = client.post(
101+
f"/api/applied-controls/{control.id}/duplicate/",
102+
{
103+
"name": "ctl copy",
104+
"description": "",
105+
"folder": str(domain_b.id),
106+
"duplicate_evidences": True,
107+
},
108+
format="json",
109+
)
110+
111+
assert response.status_code == 403, response.content
112+
assert not AppliedControl.objects.filter(folder=domain_b).exists()
113+
114+
115+
@pytest.mark.django_db
116+
def test_applied_control_duplicate_cross_domain_with_rights_on_both(domains):
117+
domain_a, domain_b = domains
118+
client = _client_for(
119+
"dup-ana-ab@tests.com",
120+
[("BI-UG-ANA", domain_a), ("BI-UG-ANA", domain_b)],
121+
)
122+
control = AppliedControl.objects.create(name="ctl", folder=domain_a)
123+
124+
response = client.post(
125+
f"/api/applied-controls/{control.id}/duplicate/",
126+
{
127+
"name": "ctl copy",
128+
"description": "",
129+
"folder": str(domain_b.id),
130+
"duplicate_evidences": False,
131+
},
132+
format="json",
133+
)
134+
135+
assert response.status_code == 200, response.content
136+
assert AppliedControl.objects.filter(name="ctl copy", folder=domain_b).exists()
137+
138+
139+
# --- risk assessments ---------------------------------------------------------
140+
141+
142+
def _make_risk_assessment(folder):
143+
matrix = RiskMatrix.objects.create(
144+
name="3x3", folder=folder, json_definition=MATRIX_DEFINITION
145+
)
146+
perimeter = Perimeter.objects.create(name="perimeter", folder=folder)
147+
return RiskAssessment.objects.create(
148+
name="ra", folder=folder, perimeter=perimeter, risk_matrix=matrix
149+
)
150+
151+
152+
@pytest.mark.django_db
153+
def test_risk_assessment_duplicate_requires_rights_on_target_domain(domains):
154+
domain_a, domain_b = domains
155+
client = _client_for("dup-ana-a@tests.com", [("BI-UG-ANA", domain_a)])
156+
risk_assessment = _make_risk_assessment(domain_a)
157+
perimeter_b = Perimeter.objects.create(name="perimeter-b", folder=domain_b)
158+
159+
response = client.post(
160+
f"/api/risk-assessments/{risk_assessment.id}/duplicate/",
161+
{
162+
"name": "ra copy",
163+
"description": "",
164+
"version": "1.0",
165+
"perimeter": str(perimeter_b.id),
166+
"folder": str(domain_b.id),
167+
},
168+
format="json",
169+
)
170+
171+
assert response.status_code == 403, response.content
172+
assert not RiskAssessment.objects.filter(folder=domain_b).exists()
173+
174+
175+
@pytest.mark.django_db
176+
def test_risk_assessment_duplicate_folder_follows_perimeter(domains):
177+
domain_a, domain_b = domains
178+
client = _client_for(
179+
"dup-ana-ab@tests.com",
180+
[("BI-UG-ANA", domain_a), ("BI-UG-ANA", domain_b)],
181+
)
182+
risk_assessment = _make_risk_assessment(domain_a)
183+
perimeter_b = Perimeter.objects.create(name="perimeter-b", folder=domain_b)
184+
185+
response = client.post(
186+
f"/api/risk-assessments/{risk_assessment.id}/duplicate/",
187+
{
188+
"name": "ra copy",
189+
"description": "",
190+
"version": "1.0",
191+
"perimeter": str(perimeter_b.id),
192+
# inconsistent on purpose: the perimeter's folder must win
193+
"folder": str(domain_a.id),
194+
},
195+
format="json",
196+
)
197+
198+
assert response.status_code == 200, response.content
199+
duplicate = RiskAssessment.objects.get(name="ra copy")
200+
assert duplicate.folder == domain_b
201+
202+
203+
@pytest.mark.django_db
204+
def test_risk_assessment_duplicate_with_scenarios_requires_scenario_rights(domains):
205+
from iam.models import Permission, Role, RoleAssignment
206+
from core.models import RiskScenario
207+
208+
domain_a, _ = domains
209+
user = User.objects.create_user("dup-ra-only@tests.com", is_published=True)
210+
user.folder = domain_a
211+
user.save()
212+
role = Role.objects.create(name="ra-only", folder=Folder.get_root_folder())
213+
role.permissions.set(
214+
Permission.objects.filter(
215+
codename__in=["view_riskassessment", "add_riskassessment"]
216+
)
217+
)
218+
assignment = RoleAssignment.objects.create(
219+
name="ra-only",
220+
user=user,
221+
role=role,
222+
folder=Folder.get_root_folder(),
223+
is_recursive=True,
224+
)
225+
assignment.perimeter_folders.add(domain_a)
226+
client = APIClient()
227+
_, token = AuthToken.objects.create(user=user)
228+
client.credentials(HTTP_AUTHORIZATION=f"Token {token}")
229+
230+
risk_assessment = _make_risk_assessment(domain_a)
231+
payload = {
232+
"name": "ra copy",
233+
"description": "",
234+
"version": "1.0",
235+
"perimeter": str(risk_assessment.perimeter.id),
236+
"folder": str(domain_a.id),
237+
}
238+
239+
# without scenarios, add_riskassessment alone is enough
240+
response = client.post(
241+
f"/api/risk-assessments/{risk_assessment.id}/duplicate/",
242+
payload,
243+
format="json",
244+
)
245+
assert response.status_code == 200, response.content
246+
247+
RiskScenario.objects.create(
248+
name="scn", folder=domain_a, risk_assessment=risk_assessment
249+
)
250+
response = client.post(
251+
f"/api/risk-assessments/{risk_assessment.id}/duplicate/",
252+
{**payload, "name": "ra copy 2"},
253+
format="json",
254+
)
255+
assert response.status_code == 403, response.content
256+
assert not RiskAssessment.objects.filter(name="ra copy 2").exists()
257+
258+
259+
# --- organisation objectives ---------------------------------------------------
260+
261+
262+
@pytest.mark.django_db
263+
def test_organisation_objective_duplicate_requires_rights_on_target_domain(domains):
264+
domain_a, domain_b = domains
265+
client = _client_for("dup-dma-a@tests.com", [("BI-UG-DMA", domain_a)])
266+
objective = OrganisationObjective.objects.create(name="obj", folder=domain_a)
267+
268+
response = client.post(
269+
f"/api/organisation-objectives/{objective.id}/duplicate/",
270+
{"name": "obj copy", "description": "", "folder": str(domain_b.id)},
271+
format="json",
272+
)
273+
274+
assert response.status_code == 403, response.content
275+
assert not OrganisationObjective.objects.filter(folder=domain_b).exists()
276+
277+
278+
@pytest.mark.django_db
279+
def test_organisation_objective_duplicate_same_domain(domains):
280+
domain_a, _ = domains
281+
client = _client_for("dup-dma-a@tests.com", [("BI-UG-DMA", domain_a)])
282+
objective = OrganisationObjective.objects.create(name="obj", folder=domain_a)
283+
284+
response = client.post(
285+
f"/api/organisation-objectives/{objective.id}/duplicate/",
286+
{"name": "obj copy", "description": "", "folder": str(domain_a.id)},
287+
format="json",
288+
)
289+
290+
assert response.status_code == 200, response.content
291+
assert OrganisationObjective.objects.filter(
292+
name="obj copy", folder=domain_a
293+
).exists()

backend/core/views.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,23 @@ def actor_prefetch(field_name: str) -> Prefetch:
978978
)
979979

980980

981+
def check_folder_add_permission(user, folder, *models):
982+
"""Raise PermissionDenied unless *user* may create each of *models* in *folder*.
983+
984+
Mirror of the check ``BaseModelSerializer.create`` performs; use it from
985+
actions that create objects directly through the ORM.
986+
"""
987+
for model in models:
988+
if not RoleAssignment.is_access_allowed(
989+
user=user,
990+
perm=Permission.objects.get(codename=f"add_{model._meta.model_name}"),
991+
folder=folder,
992+
):
993+
raise PermissionDenied(
994+
{"folder": "You do not have permission to add objects in this folder"}
995+
)
996+
997+
981998
class AutocompleteMixin:
982999
"""Adds a lightweight, server-paginated ``autocomplete`` action for entity
9831000
pickers (search/ordering/filtering come from the viewset's existing filter
@@ -4395,7 +4412,9 @@ def action_plan_pdf(self, request, pk):
43954412
serializer_class=RiskAssessmentDuplicateSerializer,
43964413
)
43974414
def duplicate(self, request, pk):
4398-
serializer = RiskAssessmentDuplicateSerializer(data=request.data)
4415+
serializer = RiskAssessmentDuplicateSerializer(
4416+
data=request.data, context={"request": request}
4417+
)
43994418
serializer.is_valid(raise_exception=True)
44004419
data = serializer.validated_data
44014420

@@ -4407,7 +4426,12 @@ def duplicate(self, request, pk):
44074426
risk_assessment = self.get_object()
44084427

44094428
perimeter = data.get("perimeter")
4410-
folder = data.get("folder")
4429+
# folder always follows the perimeter, as on update
4430+
folder = perimeter.folder if perimeter else data.get("folder")
4431+
target_models = [RiskAssessment]
4432+
if risk_assessment.risk_scenarios.exists():
4433+
target_models.append(RiskScenario)
4434+
check_folder_add_permission(request.user, folder, *target_models)
44114435

44124436
duplicate_risk_assessment = RiskAssessment.objects.create(
44134437
name=data.get("name"),
@@ -6064,7 +6088,9 @@ def get_timeline_info(self, request):
60646088
serializer_class=AppliedControlDuplicateSerializer,
60656089
)
60666090
def duplicate(self, request, pk):
6067-
serializer = AppliedControlDuplicateSerializer(data=request.data)
6091+
serializer = AppliedControlDuplicateSerializer(
6092+
data=request.data, context={"request": request}
6093+
)
60686094
serializer.is_valid(raise_exception=True)
60696095
data = serializer.validated_data
60706096

@@ -6078,6 +6104,10 @@ def duplicate(self, request, pk):
60786104

60796105
applied_control = self.get_object()
60806106
new_folder = data["folder"]
6107+
target_models = [AppliedControl]
6108+
if data["duplicate_evidences"]:
6109+
target_models.append(Evidence)
6110+
check_folder_add_permission(request.user, new_folder, *target_models)
60816111
duplicate_applied_control = AppliedControl.objects.create(
60826112
reference_control=applied_control.reference_control,
60836113
name=data["name"],
@@ -10717,7 +10747,9 @@ def is_active(self, request):
1071710747
serializer_class=OrganisationObjectiveDuplicateSerializer,
1071810748
)
1071910749
def duplicate(self, request, pk):
10720-
serializer = OrganisationObjectiveDuplicateSerializer(data=request.data)
10750+
serializer = OrganisationObjectiveDuplicateSerializer(
10751+
data=request.data, context={"request": request}
10752+
)
1072110753
serializer.is_valid(raise_exception=True)
1072210754
data = serializer.validated_data
1072310755

@@ -10730,6 +10762,7 @@ def duplicate(self, request, pk):
1073010762
)
1073110763

1073210764
new_folder = data["folder"]
10765+
check_folder_add_permission(request.user, new_folder, OrganisationObjective)
1073310766
objective = self.get_object()
1073410767
duplicate_objective = OrganisationObjective.objects.create(
1073510768
name=data["name"],

0 commit comments

Comments
 (0)