Repository navigation
feat: add endpoint to delete or archive a CompetencyCriteriaGroup (#675) #936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5ee3836
086062d
99ce820
21dded4
b061f60
426f9a5
ede2c2b
9959d8c
872b2bb
c9bb643
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |
| CompetencyCriterionArchivedError, | ||
| associate_competency_criterion, | ||
| bulk_update_competency_criteria, | ||
| delete_or_archive_competency_criteria_group, | ||
| delete_or_archive_competency_criterion, | ||
| get_blocked_courses, | ||
| get_competency_criteria_tree, | ||
|
|
@@ -230,25 +231,33 @@ def get(self, request, tag_id): | |
|
|
||
| class CompetencyCriteriaGroupDetailView(generics.GenericAPIView): | ||
| """ | ||
| PATCH-only. Change how one CompetencyCriteriaGroup combines the criteria and groups beneath it. | ||
| PATCH or DELETE one CompetencyCriteriaGroup. | ||
|
|
||
| **Example Request** | ||
| PATCH changes how the group combines the criteria and groups beneath it. DELETE hard-deletes | ||
| the group's subtree, or archives it if learner status exists anywhere in it. | ||
|
|
||
| **Example Requests** | ||
| PATCH api/cbe/v1/competencies/<tag_id>/criteria-groups/<group_id>/ | ||
| ``{"logic_operator": "AND"}`` | ||
|
|
||
| The response is the group as stored, in the criteria-tree endpoint's shape. ``logic_operator`` | ||
| DELETE api/cbe/v1/competencies/<tag_id>/criteria-groups/<group_id>/ | ||
|
|
||
| PATCH responds with the group as stored, in the criteria-tree endpoint's shape. ``logic_operator`` | ||
| is the only editable field. The body may also repeat any other field the group's representation | ||
| carries, so a client can send back what it read, as long as each one matches what is stored. | ||
|
|
||
| A thin adapter: :func:`update_competency_criteria_group` makes every other refusal, including | ||
| the edit permission check, so an in-process caller is refused in the same cases. The body's | ||
| fixed fields are compared here, before that function runs, because only a REST body carries | ||
| them. Comparing them reveals what's stored, so a caller who can't read the competency's tree is | ||
| refused with 403 before the group is looked up. | ||
| For PATCH, the view is a thin adapter: :func:`update_competency_criteria_group` makes every other | ||
| refusal, including the edit permission check, so an in-process caller is refused in the same | ||
| cases. The body's fixed fields are compared here, before that function runs, because only a REST | ||
| body carries them. Comparing them reveals what's stored, so a caller who can't read the | ||
| competency's tree is refused with 403 before the group is looked up. | ||
|
|
||
| DELETE only resolves the group from the URL. Course resolution and the permission check happen in | ||
| :func:`delete_or_archive_competency_criteria_group`, so a refused user gets 403 and a root group gets 400. | ||
| """ | ||
|
|
||
| serializer_class = CompetencyCriteriaGroupUpdateSerializer | ||
| http_method_names = ["patch"] | ||
| http_method_names = ["patch", "delete"] | ||
| authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) | ||
| permission_classes = [IsAuthenticated, CompetencyReadPermission] | ||
|
|
||
|
|
@@ -279,6 +288,39 @@ def patch(self, request, tag_id: int, group_id: int): | |
| raise CompetencyCriteriaConflict(str(exc)) from exc | ||
| return Response(CompetencyCriteriaGroupSerializer(group).data, status=status.HTTP_200_OK) | ||
|
|
||
| def delete(self, request, *args, **kwargs): | ||
| """ | ||
| Resolve the group by the `group_id` and `tag_id` in the URL, then delegate to the public API. | ||
|
|
||
| The public API checks permission. The lookup keeps archived groups, so a repeat DELETE on an | ||
| archived subtree still returns 200. | ||
| """ | ||
| group = get_object_or_404( | ||
| CompetencyCriteriaGroup, pk=self.kwargs["group_id"], tag_id=self.kwargs["tag_id"], | ||
| ) | ||
| try: | ||
| result = delete_or_archive_competency_criteria_group(group.id, request.user) | ||
| except (CompetencyCriteriaGroup.DoesNotExist, Tag.DoesNotExist) as exc: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Map concurrent course disappearance to 404. If course deletion commits after the API reads a course-level group but before course resolution reads This handler does not catch that exception. Default DRF handling therefore returns 500 instead of 404. Catch
|
||
| # Removed between the lookup above and the API call, or while the call waited for the Tag lock. | ||
| raise NotFound() from exc | ||
| except DjangoValidationError as exc: | ||
| raise DRFValidationError(exc.message_dict if hasattr(exc, "message_dict") else exc.messages) from exc | ||
| except IntegrityError as exc: | ||
| # ProtectedError is a subclass. The API already archives when the target subtree itself gained a | ||
| # status, so this is a conflict outside it, such as a status on an ancestor. A retry re-checks. | ||
| raise CompetencyCriteriaConflict( | ||
| _("The delete conflicted with a concurrent change. Retry the DELETE.") | ||
| ) from exc | ||
| return Response( | ||
| { | ||
| "id": result.id, | ||
| "archived": result.archived, | ||
| "cascaded_group_count": result.cascaded_group_count, | ||
| "cascaded_criteria_count": result.cascaded_criteria_count, | ||
| }, | ||
| status=status.HTTP_200_OK, | ||
| ) | ||
|
|
||
|
|
||
| class CompetencyCriterionDeleteView(generics.GenericAPIView): | ||
| """ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Authorization Bypass
Reachability: External
Exploitability: Moderate
CWE: CWE-862 — Missing Authorization
View Security blast radius
Run the taxonomy object-permission check before deletion.
CompetencyReadPermissionimplements onlyhas_object_permission(). Unlike PATCH, DELETE never callsself.check_object_permissions(), so declaring the permission class does not enforce that check. (raw.githubusercontent.com)If an integration overrides
oel_tagging.view_taxonomyto deny access, the defaultcan_tag_objectrule can still pass: its taxonomy check calls the base predicate directly. An authenticated course author with a known group ID can then delete or archive a subtree in that denied taxonomy. (raw.githubusercontent.com)Resolve the competency tag and call
self.check_object_permissions(request, tag)before the group lookup, as PATCH does. Based on learnings, permission checks must honor the org-awarerules.set_permoverrides. (raw.githubusercontent.com)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No change needed here. The DELETE is authorized inside
delete_or_archive_competency_criteria_group(), which requiresoel_tagging.can_tag_objectfor the competency's taxonomy and the group's course before anything is locked or changed. That rule checks the taxonomy throughuser.has_perm("oel_tagging.change_objecttag_taxonomy", ...), so an override registered withrules.set_permis honored, and openedx-platform replaces bothoel_tagging.can_tag_objectandoel_tagging.change_objecttag_taxonomywith its own rules.test_group_delete_without_taxonomy_access_is_403_and_changes_nothingdenies the taxonomy permission and gets a 403 with the subtree unchanged. The criterion delete view merged in #879 authorizes the same way.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Analysis chain
Script executed:
Length of output: 50169
@mgwozdz-uniconYou’re right that the API checksoel_tagging.can_tag_objectbefore deleting, and the cited test deniesoel_tagging.change_objecttag_taxonomy.My finding concerns a separate case: an integration could deny
oel_tagging.view_taxonomywhile allowing the write permission. The default write rule does not consult theview_taxonomyoverride, and the cited test does not cover that case.I have not verified whether openedx-platform’s overrides always keep those permissions aligned. I respect your decision not to change the code, but I’m not marking the finding as addressed.
Learnings added
Learnings used
You are interacting with an AI system.