Repository navigation
[BE] Make oel_tagging.can_tag_object's course check authorization-service aware #795
Description
Repo: openedx-platform (this fix lives entirely in openedx/core/djangoapps/content_tagging/).
Companion to openedx-core #665 (build endpoint for creating Competency Criteria): #665 calls user.has_perm('oel_tagging.can_tag_object', ...) directly to decide whether a course author may tag a subsection. That permission currently ignores the new authorization service for course objects, so #665 is a real, if not yet visible, consumer of the gap this ticket fixes. This ticket is not competency-criteria-specific: oel_tagging.can_tag_object is the general tagging permission, used by anything that tags a course object, not only CBE.
User Story
As a course team member granted tagging access only through the new authorization service, with no legacy course role, I want any code that checks oel_tagging.can_tag_object to honor that access the same way Studio's own tag-editing screen already does, in order to be able to tag content consistently no matter which feature or endpoint I use.
Acceptance Criteria
Scenario: A course switched to the new authorization service grants access through it
Given a course has been switched to the new authorization service
And a user holds courses.manage_tags on that course only through the new authorization service, with no legacy course role
When code calls user.has_perm('oel_tagging.can_tag_object', ...) directly for an object in that course
Then the permission check returns true
Scenario: A course switched to the new authorization service denies access when the new service denies it
Given a course has been switched to the new authorization service
And a user does not hold courses.manage_tags on that course through the new authorization service
When code calls user.has_perm('oel_tagging.can_tag_object', ...) directly for an object in that course
Then the permission check returns false, even if the user holds a legacy course role
And this matches the exclusive-switch behavior Studio's own tag-editing endpoint already applies, not an "either system" check
Scenario: A course not yet switched to the new authorization service is unaffected
Given a course has not been switched to the new authorization service
And a user holds a legacy course role granting studio write access
When code calls user.has_perm('oel_tagging.can_tag_object', ...) directly for an object in that course
Then the permission check returns true, exactly as it does today
Scenario: Content Library objects are unaffected
Given an object belongs to a Content Library, not a course
When code calls user.has_perm('oel_tagging.can_tag_object', ...) directly for that object
Then the permission check behaves exactly as it does today
Scenario: The direct permission check and Studio's own tag-editing endpoint agree
Given a course and a user, in any combination of switch state and role or authorization-service assignment
When Studio's own tag-editing endpoint decides whether the user may tag an object in that course
And code elsewhere calls user.has_perm('oel_tagging.can_tag_object', ...) directly for the same user and course
Then both give the same answer
Description
Current state
can_change_object_tag_objectid in openedx/core/djangoapps/content_tagging/rules.py is the predicate registered for oel_tagging.can_tag_object (via rules.set_perm), so it's what any direct user.has_perm('oel_tagging.can_tag_object', ...) call resolves to. For a Content Library object, it already checks the new authorization service (authz_api.is_user_allowed against MANAGE_LIBRARY_TAGS). For a course object, it only ever checks the legacy has_studio_write_access role check; it never looks at the new authorization service at all, regardless of whether that course has been switched over.
Meanwhile, ObjectTagOrgView in content_tagging/rest_api/v1/views.py, the view Studio's own tag-editing screen calls, already implements the correct behavior for courses: it checks should_use_course_authz_for_object(object_id) (in content_tagging/auth.py), and when that course has been switched to the new service, it checks courses.manage_tags through the authorization service exclusively, not falling back to legacy roles at all. When the course hasn't been switched, it falls back to the original permission check. This toggle-aware logic exists, but only in that one view class, not in the shared predicate every other caller of oel_tagging.can_tag_object actually depends on.
Requested change
Give can_change_object_tag_objectid's course branch the same toggle-aware behavior ObjectTagOrgView already has, so every caller of oel_tagging.can_tag_object, not just Studio's own tag-editing view, gets the correct answer. Once the shared predicate handles this itself, ObjectTagOrgView's own override duplicates the same logic in a second place; see Technical Details for whether it can be simplified away.
Explicitly out of scope
- Any change to Content Library permission handling; it's already correct and untouched by this change.
- Any change to view-only permissions (
oel_tagging.can_view_object_tagand similar). A similar course-versus-library asymmetry may exist there too, but it hasn't been verified as part of this ticket; see Open Questions. - Any change in
openedx-core(CBE's#665or anywhere else). [BE] Create CompetencyCriteria #665 needs no change: once this predicate is fixed,has_perm('oel_tagging.can_tag_object', ...)gives the right answer without [BE] Create CompetencyCriteria #665 knowing anything changed.
Technical Details
This section is background and a suggested approach, not the ticket's source of truth. The User Story and Acceptance Criteria define what must be true when the work is done; what follows exists to save the implementer some thinking, not to bind them.
In short
The fix is adding one more branch to an existing function, not building anything new. can_change_object_tag_objectid already special-cases Content Libraries before falling through to a general write-or-org-admin check. This ticket adds an equivalent special case for courses, ahead of that same fallback: when should_use_course_authz_for_object says the course has been switched over, check courses.manage_tags through the authorization service and return that answer directly, without also checking legacy roles. should_use_course_authz_for_object is the exact function ObjectTagOrgView already calls, so this reuses existing logic rather than re-deriving it.
The behavior has to be an exclusive switch, not an added "or." For a switched-over course, the new authorization service is the system of record, and legacy roles are deliberately not consulted, matching ObjectTagOrgView's own behavior. A caller who instead ORs the two checks together would let a stale legacy role continue granting access after a course has been switched over, which is a real behavior difference from what Studio's own tag-editing screen does today, not just a stylistic difference.
Once the shared predicate is fixed, ObjectTagOrgView's own override may be redundant. Its _authz_check, get_permissions, and ensure_user_has_can_tag_object_permissions overrides exist specifically because the shared predicate didn't know about the toggle. Confirm what the parent ObjectTagView's default permission handling actually does before removing anything: if it already resolves through user.has_perm('oel_tagging.can_tag_object', ...), the override in ObjectTagOrgView becomes pure duplication of logic that now lives in one place, and removing it consolidates the toggle-check to a single implementation, closing off the exact kind of drift this ticket exists to fix. If the parent's default handling does something else entirely, keep the override and say so.
Implementation specifics
- File and function to change.
openedx/core/djangoapps/content_tagging/rules.py,can_change_object_tag_objectid. Add a branch after the existing Content Libraries check and before the "general write or org-admin access" fallback: callshould_use_course_authz_for_object(object_id)(already imported elsewhere in this app fromcontent_tagging/auth.py); if it returns(True, course_key), returnauthz_api.is_user_allowed(user.username, authz_permissions.COURSES_MANAGE_TAGS.identifier, str(course_key))directly, without falling through tohas_studio_write_accessor the org-admin check. - Do not add an org-admin fallback inside the new branch.
ObjectTagOrgView's existing authz check has no equivalent fallback either; matching that exactly is what "Studio's own tag-editing endpoint and this predicate agree" actually requires. If org-level admins need to retain access after a course switches over, that has to already be modeled inside the authorization service's own role graph, not added here. ObjectTagOrgViewsimplification. Incontent_tagging/rest_api/v1/views.py, once the predicate above is confirmed correct, check whether_authz_check,get_permissions, andensure_user_has_can_tag_object_permissionscan be deleted, lettingObjectTagOrgViewfall back toObjectTagView's default permission handling. Leaveensure_has_view_object_tag_permission(the view-permission override) alone; it's a separate permission (oel_tagging.can_view_object_tagor similar) not touched by this ticket, see Open Questions.- Test strategy. Unit tests directly against
can_change_object_tag_objectid(oruser.has_perm('oel_tagging.can_tag_object', ...), whichever is more direct to set up): a course switched over with only an authz-granted role, allowed; a course switched over with no authz-granted role but a legacy role, denied; a course not switched over with a legacy role, allowed, unchanged from today; a Content Library object, unchanged from today. IfObjectTagOrgView's override is removed, extend or replace its existing permission tests to confirm identical behavior through the simplified path, so the "both paths agree" acceptance criterion is covered by an actual test, not just inspection.
Files to create and modify Modified files
| File | Nature of modification |
|---|---|
openedx/core/djangoapps/content_tagging/rules.py |
Add the course-authz-aware branch to can_change_object_tag_objectid. |
openedx/core/djangoapps/content_tagging/rest_api/v1/views.py |
Remove ObjectTagOrgView's now-redundant _authz_check, get_permissions, and ensure_user_has_can_tag_object_permissions overrides, if confirmed safe per Technical Details. |
Existing test modules for content_tagging/rules.py and content_tagging/rest_api/v1/views.py |
Add the new-branch coverage described in Technical Details; update or remove tests that exercised the overrides being deleted. |
- Context
openedx-core[BE] Create CompetencyCriteria #665: the CBE create-criterion endpoint that surfaced this gap by callinguser.has_perm('oel_tagging.can_tag_object', ...)directly. Needs no change once this ticket lands. openedx/core/djangoapps/content_tagging/auth.py'sshould_use_course_authz_for_object: the toggle-check this ticket reuses, already used byObjectTagOrgView.openedx/core/djangoapps/content_tagging/rest_api/v1/views.py'sObjectTagOrgView: the existing, correct, toggle-aware implementation this ticket generalizes out of one view class and into the shared predicate.openedx_authz.api.is_user_allowedandopenedx_authz.constants.permissions.COURSES_MANAGE_TAGS: the authorization-service check this ticket calls, already a dependency of this app.
Open Questions
- [non-blocking, owner: implementer] Does the equivalent view-only permission (
oel_tagging.can_view_object_tagor similar, checked viaensure_has_view_object_tag_permission'sCOURSES_VIEW_COURSEcheck inObjectTagOrgView) have the same course-versus-library asymmetry as the tagging permission this ticket fixes? Not verified as part of this ticket; worth checking, since the same pattern (an authz-aware override existing only in the REST view, not in the shared predicate) may repeat there.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
ormsbee commentedon Sep 6, 2026
Approved.
feat: make can_tag_object's course check authorization-service aware
feat: make can_tag_object's course check authorization-service aware
feat: make can_tag_object's course check authorization-service aware
feat: make can_tag_object's course check authorization-service aware
dvarenikqaconsultant commentedon Sep 26, 2026
Environment: local Tutor dev environment (not Master Sandbox, see below).
openedx-platformwas on upstreammasterat303f778c16, which includes the merged PR openedx/openedx-platform#39112 (commitecb9f5ef4b).Test users:
authz_user(course role granted through openedx-authz only),legacy_user(legacy course role only),qa795_admin(superuser used for setup).Test type: Django shell checks plus API requests (curl with a JWT from the LMS session login).
Test method: the manual testing notes left by the developer in openedx/openedx-platform#39112, extended to cover every acceptance criterion of this ticket.
Result: all acceptance criteria passed. No bugs found.
Test data
course-v1:QA795+CS795+2026with one sectionblock-v1:QA795+CS795+2026+type@chapter+block@ch795.QA-795(id 8) enabled for all orgs, with the tagTag795.lib:QA795:lib795with one componentlb:QA795:lib795:problem:p795.authz.enable_course_authoringfor the course, switched between Force Off and Force On as the developer's notes describe.legacy_userwas added as course Staff while the flag was Force Off, so the role was stored as a legacy role.authz_userwas added as course Staff after the flag was switched to Force On, so the role was stored in openedx-authz only. Roles were granted throughadd_users, the same call the Studio Course Team page makes.Before running the checks, I confirmed the stored roles directly.
authz_userhad noCourseAccessRolerow and one authz assignmentCourse Staff @ course-v1:QA795+CS795+2026.legacy_userhad oneCourseAccessRolerowstaffand no authz assignment.Results
Each check was run against both the course key and the section inside the course. "Predicate" is
can_change_object_tag_objectid(user, object_id), and "has_perm" isuser.has_perm('oel_tagging.can_tag_object', ObjectTagPermissionItem(taxonomy, object_id)). The API column isPUT /api/content_tagging/v1/object_tags/<object_id>/with{"tagsData": [{"taxonomy": 8, "tags": ["Tag795"]}]}.ObjectTagOrgView)authz_user(authz Course Staff only)True/Truelegacy_user(legacy staff only)False/Falselegacy_user(legacy staff)True/Trueauthz_user(no role yet)False/Falselegacy_user(library author)True/Trueauthz_user(no library role)False/Falsehas_permcheck agreeThe unit tests added by the PR (
TestRulesCourseAuthzPermissions, 5 tests) also passed locally.Additionally tested
Observation: changed 403 message
For
legacy_useron a course with the flag Force On, the 403 response text changed. It used to beYou do not have permission to manage object tags.and is nowYou do not have permission to change object tags for Taxonomy: .... This follows from the PR removing the view's ownensure_user_has_can_tag_object_permissionsoverride, so the message now comes from the parent view in openedx-core. The status code is unchanged, and this is not an acceptance criterion. It only matters if a frontend or test depends on the old wording.Implementation note: what the API check proves
ObjectTagOrgViewalready returned 200 and 403 in exactly these cases before this PR, because it had its own copy of the authz-aware check. So the API column confirms there is no regression after that copy was removed. It does not prove the fix itself. The fix is proven by the predicate andhas_permcolumns.Why this was not tested on Master Sandbox
Master Sandbox only supports UI actions and Postman requests. Running a Django shell or scripts there is not possible. That rules out the check that actually proves this fix, for three reasons:
has_perm('oel_tagging.can_tag_object', ...)directly today is from a Django shell, which is what the developer's notes use.ObjectTagOrgView, answered the same way before and after the PR (see the implementation note above). Testing it through Postman would only repeat the regression check, not verify AC1 or AC2.oel_tagging.can_tag_objectdirectly, and would therefore show the fix over HTTP, is the create-criterion endpoint from [BE] Create CompetencyCriteria #665. It is not implemented yet, and [BE] Create CompetencyCriteria #665 is blocked by [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 (CBE data models), which is also still open.What will be tested on Master Sandbox later
Once #613 and #665 are merged and deployed to Master Sandbox, the following will be verified through Postman against the #665 endpoint (
POST /api/cbe/v1/competencies/<tag_id>/criteria/with a course section asobject_id). The setup uses the same data shape as above: a course with theauthz.enable_course_authoringoverride set in Django admin, and users added through the Studio Course Team page.AC4 will not be retested on Master Sandbox. The #665 endpoint rejects Content Library objects by design, and the library branch of the predicate is unchanged by this PR. The local results above cover it.
#674 and #675 (delete criterion and delete criteria group) use the same permission check as #665. Once they are implemented, they give two more places where AC1 and AC2 can be checked through Postman.
dvarenikqaconsultant commentedon Oct 19, 2026
Test report: #795 on Master Sandbox
Environment: Master Sandbox (https://studio.master.openedx.io, https://apps.master.openedx.io)
Date: 2026-10-19
Test users:
test670(non-staff, used for every permission check),dmitryvarenikqa(staff, used only for setup: Django admin, Admin Console, imports)Test type: API testing. Studio
PUT /api/content_tagging/v1/object_tags/<object_id>/and the #665 endpointPOST /api/cbe/v1/competencies/<tag_id>/criteria/, sent from an authenticated browser sessionImplementation under test: openedx/openedx-platform#39112 (merged 2026-09-24). The #665 endpoint is part of openedx-core 1.12.0, which is the version pinned in openedx-platform master.
Result: ✅ All 5 acceptance criteria passed, plus 3 additional checks. No bugs found in #795.
Because the ACs are written as direct
user.has_perm('oel_tagging.can_tag_object', ...)calls, they were verified through the #665 endpoint, which calls that permission check directly. The Studio tagging endpoint was used as a second path for comparison (AC5) and for the Content Library case (AC4), since the #665 endpoint does not accept library objects.One user played every role in sequence:
test670was added to Course Team as Staff on courses A and B while course A's flag was Force Off. A control request confirmed that the legacy role worked: Studio PUT on A2 returned 200. The flag on course A was then switched to Force On.test670got an org-levelstaffrole for org QA795.test670got Course Staff on course A through the Admin Console, so the role exists only in openedx-authz.test670got Library Author onlib:QA795:lib795through the Admin Console.Acceptance criteria
object_id= A1id,group_id,object_tag_id,rule_profile_id;object_idandlogic_operatorare not returnedobject_id= A2object_id= B1lb:QA795:lib795:problem:p795AC5 details:
Additionally tested
staffrole for QA795 and no role on course A, Studio PUT on A2 and POST criteria on A2 both returned 403, so org-level admin access is not used as a fallback on a course with the flag on. As a control, the same org-level role on course B (flag off, no course role) gave a 200 on Studio PUT.block-v1:QA795+A+2026+type@vertical+block@a1_unit) returned 403. Objects inside a course are checked at the course level.