You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Task - Audit Authoring Views for Permission Edge Cases #286
Perform a manual audit of the Authoring flow and key views (Course Overview, Units Overview) to ensure UI stability. We need to verify that the application doesn't crash or break when users have restricted or missing permissions.
Scope of Work
Flow Audit: Navigate through the Authoring module using the new granular roles (Editor and Auditor).
Critical Views: Verify Course Overview and Units Overview render correctly without permission errors.
Error Handling: Ensure the UI handles empty states or restricted data gracefully.
Bug Reporting: Document any UI breaks or inconsistent states found during the audit.
1. Wrap any link to Proctored Exams Settings with courses.manage_pages_and_resources
Screencast.from.2026-09-02.14-48-12.webm
From the backend side
ProctoredExamSettingsView (GET and POST) is not authz-validated, in this case it should validate courses.manage_pages_and_resources . Only its sibling ProctoringErrorsView is (used for advanced settings page).
2. Remove the link to the library in a unit when the user does not have permission for that specific library
When a unit content comes from a library the course outline displays an Info Alert at the top with 2 links
Learn more: that redirect to Open edX documentation about Library Updates.
library content: Redirect to the library. We can validate whether a user hold permission for the library and allow the redirection or display plain text when is not allowed.
Screencast.from.2026-09-02.15-43-37.webm
3. As Curse Editor I cannot tag a unit or content that has not be tagged before
The drawer makes two calls, and only one of them knows about openedx-authz:
GET /api/content_tagging/v1/taxonomies/?org=X returns what you can tag with ❌ no authz aware
GET /api/content_tagging/v1/object_tags/{id} returns tags already on the block ✅ authz aware
get_admin_orgs / get_user_orgs (content_tagging/rules.py) resolve only legacy roles — OrgStaffRole, OrgInstructorRole, OrgContentCreatorRole, OrgLibraryUserRole, and CourseStaffRole/CourseInstructorRole rows in student_courseaccessrole. A user who holds only the authz course_editor role has no such rows, so both lists are empty and the queryset is emptied — including the all-org (org=None) taxonomy, since the early return happens before the Q(org=None) clause is ever reached. Empty list → nothing to add.
Screencast.from.2026-09-02.16-42-51.webm
I not sure if that could be the correct behavior. Or whether this overlaps the taxonomy permission, that has not been implemented. Because of this, the following message is displayed and still display the manage button:
Other tags
These tags are already applied, but you can't add new ones as you don't have access to their taxonomies.
4. As Cause Auditor/Course Editor when open the library updates modal the libraries API return 403 if I don't have libraries.view_library permission.
We will review the failing endpoints that should allow the review of the changes. A user with the courses.manage_library_updates permission should be able to view the changes even if they don't have view permissions in the library, similar to how it was resolved for the sync endpoint (Fix review endpoints to allow access for users with courses.view_library_updates permission #441).
1. View-only users (e.g. course auditor) can open course-app settings in Pages & Resources through a deep link
All course-app settings in Pages & Resources are opened through the same route, /pages-and-resources/:appId/settings, which loads the matching @openedx-plugins/course-app-<appId> package from plugins/course-apps. For view-only users (view_pages_and_resources without manage_pages_and_resources) the settings button is hidden, but the route itself has no permission check, so the modal can still be opened by URL.
How the modal behaves then depends on how the plugin is built:
Plugins built on AppSettingsModal (e.g. Progress, Calculator) read isEditable from PagesAndResourcesContext and disable their controls, so the modal opens read-only.
Plugins with a custom modal (e.g. ORA, Proctoring, Xpert unit summaries) don't read isEditable, so the modal opens with all options enabled (e.g. /pages-and-resources/ora_settings/settings). Saving fails only because the backend rejects the request.
Screencast.from.2026-10-21.11-49-04.webm
Possible solutions:
Guard the settings routes in PagesAndResources (:appId/settings and the Discussions routes) with the manage permission. View-only users are redirected to Pages & Resources and no modal is shown, whichever way the plugin implements its modal.
Screencast.from.2026-10-21.11-52-17.webm
Complete the validation of isEditable for each custom modal as AppSettingsModal does.
Caveat: The route guard only covers course apps installed at build time from plugins/course-apps (or other @openedx-plugins/course-app-* packages), this route. Plugins added through a plugin slot (e.g. additional_course_plugin) don't use routes and open their own UI, so they have to check permissions themselves, for example by readidResourcesContext`.
In addition, custom pages is also unguarded but in their own path /custom-pages we still can disable actions based on manage.pages_and_resources as this one is the only entrance point.
2. Course Editor can not save some of the settings in pages and resources
Teams and ORA settings can't be saved because they only validate advanced_settings this is because there are some settings that are distributed between both views (Advance Settings and Pages and Resources).
Screencast.from.2026-10-21.16-53-58.webm
The frontend already disabled the button for teams and wiki
We can do the same but that does not solve the deep link, so if we want to keep this 4 apps (ORA, teams, progrss and wiki) manage by advance settings only we need to disabled the buttons directly in the modal or allow at the backed side both permissions for this 4 apps.
3. Editor can't import YouTube transcripts and gets no error
Cause: every /transcripts/* view goes through _get_item (views/transcripts_ajax.py:775-783), which uses has_course_author_access. Editors get a 403 on /transcripts/check. The MFE only shows the import card when command == "import" and ignores the error (VideoSourceWidget/hooks.jsx:18-27).
Screencast.from.2026-10-21.16-11-34.webm
Fix:
Backend: in _get_item, replace the legacy check with user_has_course_permission(..., COURSES_EDIT_COURSE_CONTENT, ..., LegacyAuthoringPermission.WRITE). All of these views serve the video editor, which requires edit permission anyway.
MFE (optional): surface an error when checkTranscriptsForImport fails, so a denied request isn't silent.
4. XBlock (such as Drag and Drop, survey ,etc) studio handler drops the course editor's writes silently
Cause:component_handler (views/component.py:567) decides whether to persist with the legacy has_course_author_access. Editors have no legacy role (LEGACY_COURSE_ROLE_EQUIVALENCES doesn't include them), so update_item is skipped and only a warning is logged. The handler still returns 200, so the UI reports success.
The else branch should probably return 403 instead of failing quietly, at least when the handler actually changed fields.
Finding: Pages & Resources → Live can't load
Who: everyone with authz-only roles
Evidence: course_live/providers and course_live/course → 403
Cause: course_live/permissions.pyIsStaffOrInstructor only accepts legacy GlobalStaff, CourseInstructorRole or CourseStaffRole
Fix: Use user_has_course_permission: view for GET, MANAGE_PAGES_AND_RESOURCES for writes
6. Backend only it requires direct API call, so leave them separately from the ones reachable from the UI
This require a second opinion there are more like security validation at the API level.
a. Editor can publish by adding metadata to make_public
Cause:_get_xblock_authz_permission (view_handlers.py:216-229) chooses the permission from the shape of the request body. It only asks for publish_course_content on a "pure" publish. Once the body contains any content field (_CONTENT_FIELDS, :109), it asks for edit_course_content instead. _save_xblock then publishes anyway (:696-702).
Fix: decide by what the request does. If it will publish, require publish permission as well as edit:
Then check EDIT_COURSE_CONTENT (when there's content), PUBLISH_COURSE_CONTENT (when needs_publish), or both. The simplest way is for _get_xblock_authz_permission to return a list and for _check_xblock_permission to require all of them. Product needs to decide one case: an editor changing visibility on a published unit. Either deny it (403), or save the change as a draft and downgrade republish to no publish.
b. Duplicating from another course writes into that course
Cause: there are two bugs.
When authz is on, nothing checks access to the source course. The read check on the source only runs on the legacy path (view_handlers.py:362-366).
duplicate_block builds the new key from the source location (contentstore/utils.py:1132: source_item.location.replace(name=uuid4().hex)). So the new block is created in the source course and then attached as a child of a parent in another course. That creates a reference across courses and leaves a structure that can't be deleted (KeyError).
Fix:
Reject duplicates across courses: if source_course != parent_usage_key.course_key: raise PermissionDenied (or return 400). Studio only duplicates within a single course, so nothing legitimate breaks.
If the product ever wants duplicates across courses, check VIEW_COURSE , and build dest_usage_key in the parent's course.
c. ContainerChildrenView has no permission check
Cause:rest_api/v1/views/course_index.py:125 only applies view_auth_classes(is_authenticated=True). Unlike its sibling ContainerHandlerView, it never calls _get_item_in_course, so it never checks course access. This was already the case on upstream m
This is safe for the UI, because the unit page already requires the same permission through container_handler. Related: get_xblock creates course_info blocks on a GET when they don't exist (view_handlers.py:1140), so the check has to come before that
Activity
MaferMazu commentedon Jun 9, 2026
@gviedma-aulasneo will create a requirement list to review this ticket.
dcoa commentedon Sep 2, 2026
Findings
1. Wrap any link to Proctored Exams Settings with
courses.manage_pages_and_resourcesScreencast.from.2026-09-02.14-48-12.webm
From the backend side
ProctoredExamSettingsView(GET and POST) is not authz-validated, in this case it should validatecourses.manage_pages_and_resources. Only its siblingProctoringErrorsViewis (used for advanced settings page).2. Remove the link to the library in a unit when the user does not have permission for that specific library
When a unit content comes from a library the course outline displays an Info Alert at the top with 2 links
Screencast.from.2026-09-02.15-43-37.webm
3. As Curse Editor I cannot tag a unit or content that has not be tagged before
The drawer makes two calls, and only one of them knows about openedx-authz:
/api/content_tagging/v1/taxonomies/?org=Xreturns what you can tag with ❌ no authz aware/api/content_tagging/v1/object_tags/{id}returns tags already on the block ✅ authz awareget_admin_orgs / get_user_orgs(content_tagging/rules.py) resolve only legacy roles — OrgStaffRole, OrgInstructorRole, OrgContentCreatorRole, OrgLibraryUserRole, and CourseStaffRole/CourseInstructorRole rows in student_courseaccessrole. A user who holds only the authzcourse_editorrole has no such rows, so both lists are empty and the queryset is emptied — including the all-org (org=None) taxonomy, since the early return happens before theQ(org=None)clause is ever reached. Empty list → nothing to add.Screencast.from.2026-09-02.16-42-51.webm
I not sure if that could be the correct behavior. Or whether this overlaps the taxonomy permission, that has not been implemented. Because of this, the following message is displayed and still display the manage button:
4. As Cause Auditor/Course Editor when open the library updates modal the libraries API return 403 if I don't have
libraries.view_librarypermission.Related endpoints:
/api/libraries/v2/containers/<library_unit_id>/children/?published=true/api/libraries/v2/containers/<library_unit_id>//xblocks/v2/lb:Demo:L1:html:92963022-f922-460c-8950-f3887591035b/embed/student_view/?version=publishedScreencast.from.2026-09-02.19-14-49.webm
BryanttV commentedon Sep 15, 2026
Hi @dcoa, thanks for the detailed report.
courses.manage_pages_and_resourcespermission #442). From the backend, RBAC permissions will be validated for the proctoring view (GET and POST). In theory, it should also usecourses.manage_pages_and_resources(Add authz validation forcourses.manage_pages_and_resourcestoProctoredExamSettingsView#443).content_libraries.view_library) (Remove the link to the library in a unit when the user does not have permission for that specific library #450)TaxonomyOrgViewauthz-aware to allow access for users withcourses.manage_tagspermission #448)courses.manage_library_updatespermission should be able to view the changes even if they don't have view permissions in the library, similar to how it was resolved for the sync endpoint (Fix review endpoints to allow access for users withcourses.view_library_updatespermission #441).dcoa commentedon Oct 21, 2026
Findings
1. View-only users (e.g. course auditor) can open course-app settings in Pages & Resources through a deep link
All course-app settings in Pages & Resources are opened through the same route,
/pages-and-resources/:appId/settings, which loads the matching@openedx-plugins/course-app-<appId>package fromplugins/course-apps. For view-only users (view_pages_and_resourceswithoutmanage_pages_and_resources) the settings button is hidden, but the route itself has no permission check, so the modal can still be opened by URL.How the modal behaves then depends on how the plugin is built:
AppSettingsModal(e.g. Progress, Calculator) readisEditablefromPagesAndResourcesContextand disable their controls, so the modal opens read-only.isEditable, so the modal opens with all options enabled (e.g./pages-and-resources/ora_settings/settings). Saving fails only because the backend rejects the request.Screencast.from.2026-10-21.11-49-04.webm
Possible solutions:
PagesAndResources(:appId/settingsand the Discussions routes) with the manage permission. View-only users are redirected to Pages & Resources and no modal is shown, whichever way the plugin implements its modal.Screencast.from.2026-10-21.11-52-17.webm
isEditablefor each custom modal asAppSettingsModaldoes.In addition, custom pages is also unguarded but in their own path
/custom-pageswe still can disable actions based onmanage.pages_and_resourcesas this one is the only entrance point.2. Course Editor can not save some of the settings in pages and resources
Teams and ORA settings can't be saved because they only validate
advanced_settingsthis is because there are some settings that are distributed between both views (Advance Settings and Pages and Resources).Screencast.from.2026-10-21.16-53-58.webm
The frontend already disabled the button for teams and wiki
We can do the same but that does not solve the deep link, so if we want to keep this 4 apps (ORA, teams, progrss and wiki) manage by advance settings only we need to disabled the buttons directly in the modal or allow at the backed side both permissions for this 4 apps.
3. Editor can't import YouTube transcripts and gets no error
Cause: every /transcripts/* view goes through
_get_item(views/transcripts_ajax.py:775-783), which useshas_course_author_access. Editors get a 403 on/transcripts/check. The MFE only shows the import card whencommand == "import"and ignores the error (VideoSourceWidget/hooks.jsx:18-27).Screencast.from.2026-10-21.16-11-34.webm
Fix:
_get_item, replace the legacy check withuser_has_course_permission(..., COURSES_EDIT_COURSE_CONTENT, ..., LegacyAuthoringPermission.WRITE). All of these views serve the video editor, which requires edit permission anyway.checkTranscriptsForImportfails, so a denied request isn't silent.4. XBlock (such as Drag and Drop, survey ,etc) studio handler drops the course editor's writes silently
Cause:
component_handler(views/component.py:567) decides whether to persist with the legacyhas_course_author_access. Editors have no legacy role (LEGACY_COURSE_ROLE_EQUIVALENCESdoesn't include them), soupdate_itemis skipped and only a warning is logged. The handler still returns 200, so the UI reports success.Screencast.from.2026-10-21.16-04-03.webm
Fix:
The else branch should probably return 403 instead of failing quietly, at least when the handler actually changed fields.
Who: everyone with authz-only roles
Evidence:
course_live/providers and course_live/course→ 403Cause:
course_live/permissions.pyIsStaffOrInstructoronly accepts legacyGlobalStaff, CourseInstructorRole or CourseStaffRoleFix: Use
user_has_course_permission:view for GET,MANAGE_PAGES_AND_RESOURCESfor writes6. Backend only it requires direct API call, so leave them separately from the ones reachable from the UI
This require a second opinion there are more like security validation at the API level.
a. Editor can publish by adding metadata to make_public
Cause:
_get_xblock_authz_permission(view_handlers.py:216-229) chooses the permission from the shape of the request body. It only asks forpublish_course_contenton a "pure" publish. Once the body contains any content field (_CONTENT_FIELDS, :109), it asks foredit_course_contentinstead._save_xblockthen publishes anyway (:696-702).Fix: decide by what the request does. If it will publish, require publish permission as well as edit:
Then check
EDIT_COURSE_CONTENT(when there's content),PUBLISH_COURSE_CONTENT(whenneeds_publish), or both. The simplest way is for_get_xblock_authz_permissionto return a list and for _check_xblock_permission to require all of them. Product needs to decide one case: an editor changing visibility on a published unit. Either deny it (403), or save the change as a draft and downgrade republish to no publish.b. Duplicating from another course writes into that course
Cause: there are two bugs.
duplicate_blockbuilds the new key from the source location (contentstore/utils.py:1132: source_item.location.replace(name=uuid4().hex)). So the new block is created in the source course and then attached as a child of a parent in another course. That creates a reference across courses and leaves a structure that can't be deleted (KeyError).Fix:
source_course != parent_usage_key.course_key: raise PermissionDenied (or return 400). Studio only duplicates within a single course, so nothing legitimate breaks.VIEW_COURSE, and builddest_usage_keyin the parent's course.c.
ContainerChildrenViewhas no permission checkCause:
rest_api/v1/views/course_index.py:125only appliesview_auth_classes(is_authenticated=True). Unlike its siblingContainerHandlerView, it nevercalls _get_item_in_course, so it never checks course access. This was already the case on upstream mFix: at the top of
get():This is safe for the UI, because the unit page already requires the same permission through
container_handler.Related:get_xblockcreatescourse_infoblocks on a GET when they don't exist (view_handlers.py:1140), so the check has to come before that