Skip to content

Commit 7171334

Browse files
committed
Merge remote-tracking branch 'origin/dev' into stackable
2 parents 091c9f6 + b41b23c commit 7171334

45 files changed

Lines changed: 2658 additions & 758 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,8 @@ __pycache__
2828
charts/secobserve/charts/postgresql-16.7.27.tgz
2929
charts/secobserve/my_values.yaml
3030
.kilo/
31+
32+
openspec/
33+
.claude/
34+
.mcp.json
35+
CLAUDE.md

backend/application/access_control/api/filters.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Optional
22

3-
from django.db.models import Exists, QuerySet
3+
from django.db.models import Exists, OuterRef, Q, QuerySet
44
from django_filters import CharFilter, FilterSet, NumberFilter, OrderingFilter
55
from rest_framework.request import Request
66

@@ -10,6 +10,23 @@
1010
Authorization_Group_Member,
1111
User,
1212
)
13+
from application.authorization.services.roles_permissions import Roles
14+
from application.core.models import (
15+
Product,
16+
Product_Authorization_Group_Member,
17+
Product_Member,
18+
)
19+
20+
21+
def _get_product_and_group_ids(product_id: int) -> list[int]:
22+
product = Product.objects.filter(id=product_id).select_related("product_group").first()
23+
if product is None:
24+
return []
25+
26+
product_ids = [product.id]
27+
if product.product_group_id:
28+
product_ids.append(product.product_group_id)
29+
return product_ids
1330

1431

1532
class UserFilter(FilterSet):
@@ -23,6 +40,57 @@ class UserFilter(FilterSet):
2340
exclude_license_group = NumberFilter(field_name="exclude_license_group", method="get_exclude_license_group")
2441
exclude_license_policy = NumberFilter(field_name="exclude_license_policy", method="get_exclude_license_policy")
2542
exclude_product = NumberFilter(field_name="exclude_product", method="get_exclude_product")
43+
member_of_product = NumberFilter(field_name="member_of_product", method="get_member_of_product")
44+
assessment_approver_for_product = NumberFilter(
45+
field_name="assessment_approver_for_product", method="get_assessment_approver_for_product"
46+
)
47+
48+
def get_member_of_product(
49+
self,
50+
queryset: QuerySet,
51+
name: Any, # pylint: disable=unused-argument
52+
value: Any,
53+
) -> QuerySet:
54+
# Users who are members of the product - directly or via an authorization group - including
55+
# membership inherited from the product's product group. The "authorization_groups" relation
56+
# is traversed twice: User -> authorization groups -> products the group is assigned to.
57+
if value is not None:
58+
return queryset.filter(
59+
Q(product_members__id=value) # direct member of the product
60+
| Q(product_members__products__id=value) # direct member of the product group
61+
| Q(authorization_groups__authorization_groups__id=value) # via an authorization group of the product
62+
| Q(authorization_groups__authorization_groups__products__id=value) # via an auth group of the group
63+
).distinct()
64+
return queryset
65+
66+
def get_assessment_approver_for_product(
67+
self,
68+
queryset: QuerySet,
69+
name: Any, # pylint: disable=unused-argument
70+
value: Any,
71+
) -> QuerySet:
72+
product_ids = _get_product_and_group_ids(value)
73+
if not product_ids:
74+
return queryset.none()
75+
76+
product_members = Product_Member.objects.filter(
77+
product_id__in=product_ids,
78+
user=OuterRef("pk"),
79+
role__gte=Roles.Writer,
80+
)
81+
product_authorization_group_members = Product_Authorization_Group_Member.objects.filter(
82+
product_id__in=product_ids,
83+
authorization_group__users=OuterRef("pk"),
84+
role__gte=Roles.Writer,
85+
)
86+
return (
87+
queryset.annotate(
88+
assessment_approver_product_member=Exists(product_members),
89+
assessment_approver_group_member=Exists(product_authorization_group_members),
90+
)
91+
.filter(Q(assessment_approver_product_member=True) | Q(assessment_approver_group_member=True))
92+
.distinct()
93+
)
2694

2795
def get_exclude_authorization_group(
2896
self,
@@ -116,6 +184,43 @@ class AuthorizationGroupFilter(FilterSet):
116184
exclude_license_group = NumberFilter(field_name="exclude_license_group", method="get_exclude_license_group")
117185
exclude_license_policy = NumberFilter(field_name="exclude_license_policy", method="get_exclude_license_policy")
118186
exclude_product = NumberFilter(field_name="exclude_product", method="get_exclude_product")
187+
member_of_product = NumberFilter(field_name="member_of_product", method="get_member_of_product")
188+
assessment_approver_for_product = NumberFilter(
189+
field_name="assessment_approver_for_product", method="get_assessment_approver_for_product"
190+
)
191+
192+
def get_member_of_product(
193+
self,
194+
queryset: QuerySet,
195+
name: Any, # pylint: disable=unused-argument
196+
value: Any,
197+
) -> QuerySet:
198+
# Authorization groups assigned to the product, including those inherited from its product group.
199+
if value is not None:
200+
return queryset.filter(
201+
Q(authorization_groups__id=value) # assigned to the product
202+
| Q(authorization_groups__products__id=value) # assigned to the product's product group
203+
).distinct()
204+
return queryset
205+
206+
def get_assessment_approver_for_product(
207+
self,
208+
queryset: QuerySet,
209+
name: Any, # pylint: disable=unused-argument
210+
value: Any,
211+
) -> QuerySet:
212+
product_ids = _get_product_and_group_ids(value)
213+
if not product_ids:
214+
return queryset.none()
215+
216+
product_authorization_group_members = Product_Authorization_Group_Member.objects.filter(
217+
product_id__in=product_ids,
218+
authorization_group=OuterRef("pk"),
219+
role__gte=Roles.Writer,
220+
)
221+
return queryset.annotate(
222+
assessment_approver_product_authorization_group=Exists(product_authorization_group_members),
223+
).filter(assessment_approver_product_authorization_group=True)
119224

120225
def get_exclude_license_group(
121226
self,

backend/application/authorization/services/authorization.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
get_highest_role_of_product_authorization_group_members_for_user,
2323
get_product_member,
2424
)
25+
from application.core.services.assessment import (
26+
assessment_approvers_configured,
27+
is_user_designated_assessment_approver,
28+
)
2529
from application.import_observations.models import (
2630
Api_Configuration,
2731
Vulnerability_Check,
@@ -52,7 +56,11 @@ def user_has_permission( # pylint: disable=too-many-return-statements,too-many-
5256
and permission not in Permissions.get_product_group_permissions()
5357
):
5458
role = get_highest_user_role(obj, user)
55-
return bool(role and role_has_permission(role, permission))
59+
has_role_permission = bool(role and role_has_permission(role, permission))
60+
# With designated approvers configured, only they (with an approval-capable role) may approve assessments.
61+
if permission == Permissions.Observation_Log_Approval and assessment_approvers_configured(obj):
62+
return has_role_permission and (role == Roles.Owner or is_user_designated_assessment_approver(obj, user))
63+
return has_role_permission
5664

5765
if isinstance(obj, Product) and obj.is_product_group:
5866
role = get_highest_user_role(obj, user)

backend/application/core/api/serializers_observation.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,12 @@ class ObservationLogApprovalSerializer(Serializer):
655655
assessment_status = ChoiceField(choices=Assessment_Status.ASSESSMENT_STATUS_CHOICES_APPROVAL, required=True)
656656
rejection_remark = CharField(max_length=255, required=False, allow_blank=True)
657657
observation_log_comment = CharField(max_length=4096, required=False, allow_blank=True)
658+
observation_log_vex_justification = ChoiceField(
659+
choices=VEX_Justification.VEX_JUSTIFICATION_CHOICES,
660+
required=False,
661+
allow_blank=True,
662+
)
663+
observation_log_vex_remediations = JSONField(required=False, allow_null=True)
658664

659665
def validate(self, attrs: dict) -> dict:
660666
if attrs.get("assessment_status") in [
@@ -671,8 +677,13 @@ def validate(self, attrs: dict) -> dict:
671677
if attrs.get("assessment_status") in [
672678
Assessment_Status.ASSESSMENT_STATUS_APPROVED,
673679
Assessment_Status.ASSESSMENT_STATUS_REJECTED,
674-
] and attrs.get("observation_log_comment"):
675-
raise ValidationError("Comment for observation Log cannot be set with approval or rejection")
680+
]:
681+
if attrs.get("observation_log_comment"):
682+
raise ValidationError("Comment for observation Log cannot be set with approval or rejection")
683+
if attrs.get("observation_log_vex_justification"):
684+
raise ValidationError("VEX justification for observation log cannot be set with approval or rejection")
685+
if attrs.get("observation_log_vex_remediations"):
686+
raise ValidationError("VEX remediation for observation log cannot be set with approval or rejection")
676687

677688
if attrs.get("assessment_status") == Assessment_Status.ASSESSMENT_STATUS_APPROVED_WITH_EDITS and not attrs.get(
678689
"observation_log_comment"

0 commit comments

Comments
 (0)