Skip to content

[BE] Make oel_tagging.can_tag_object's course check authorization-service aware #795

Closed

Description

@thelmick-unicon

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_tag and 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 #665 or 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: call should_use_course_authz_for_object(object_id) (already imported elsewhere in this app from content_tagging/auth.py); if it returns (True, course_key), return authz_api.is_user_allowed(user.username, authz_permissions.COURSES_MANAGE_TAGS.identifier, str(course_key)) directly, without falling through to has_studio_write_access or 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.
  • ObjectTagOrgView simplification. In content_tagging/rest_api/v1/views.py, once the predicate above is confirmed correct, check whether _authz_check, get_permissions, and ensure_user_has_can_tag_object_permissions can be deleted, letting ObjectTagOrgView fall back to ObjectTagView's default permission handling. Leave ensure_has_view_object_tag_permission (the view-permission override) alone; it's a separate permission (oel_tagging.can_view_object_tag or similar) not touched by this ticket, see Open Questions.
  • Test strategy. Unit tests directly against can_change_object_tag_objectid (or user.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. If ObjectTagOrgView'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 calling user.has_perm('oel_tagging.can_tag_object', ...) directly. Needs no change once this ticket lands.
  • openedx/core/djangoapps/content_tagging/auth.py's should_use_course_authz_for_object: the toggle-check this ticket reuses, already used by ObjectTagOrgView.
  • openedx/core/djangoapps/content_tagging/rest_api/v1/views.py's ObjectTagOrgView: the existing, correct, toggle-aware implementation this ticket generalizes out of one view class and into the shared predicate.
  • openedx_authz.api.is_user_allowed and openedx_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_tag or similar, checked via ensure_has_view_object_tag_permission's COURSES_VIEW_COURSE check in ObjectTagOrgView) 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.

Activity

  1. moved this from Needs additional details to Ready for Community Review in Competency Criteria and Student Progresson Sep 1, 2026
  2. ormsbee commented on Sep 6, 2026

    @ormsbee
    Contributor

    Approved.

  3. moved this from Ready for Community Review to Todo in Competency Criteria and Student Progresson Sep 8, 2026
  4. moved this from In Progress to Unicon Code Review in Competency Criteria and Student Progresson Sep 16, 2026
  5. moved this from Unicon Code Review to Community Code Review in Competency Criteria and Student Progresson Sep 18, 2026
  6. moved this from Community Code Review to Ready for QA in Competency Criteria and Student Progresson Sep 24, 2026
  7. dvarenikqaconsultant commented on Sep 26, 2026

    @dvarenikqaconsultant

    Environment: local Tutor dev environment (not Master Sandbox, see below). openedx-platform was on upstream master at 303f778c16, which includes the merged PR openedx/openedx-platform#39112 (commit ecb9f5ef4b).
    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 course-v1:QA795+CS795+2026 with one section block-v1:QA795+CS795+2026+type@chapter+block@ch795.
    • Taxonomy QA-795 (id 8) enabled for all orgs, with the tag Tag795.
    • Content Library lib:QA795:lib795 with one component lb:QA795:lib795:problem:p795.
    • Course waffle flag override authz.enable_course_authoring for the course, switched between Force Off and Force On as the developer's notes describe.
    • legacy_user was added as course Staff while the flag was Force Off, so the role was stored as a legacy role. authz_user was added as course Staff after the flag was switched to Force On, so the role was stored in openedx-authz only. Roles were granted through add_users, the same call the Studio Course Team page makes.

    Before running the checks, I confirmed the stored roles directly. authz_user had no CourseAccessRole row and one authz assignment Course Staff @ course-v1:QA795+CS795+2026. legacy_user had one CourseAccessRole row staff and 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" is user.has_perm('oel_tagging.can_tag_object', ObjectTagPermissionItem(taxonomy, object_id)). The API column is PUT /api/content_tagging/v1/object_tags/<object_id>/ with {"tagsData": [{"taxonomy": 8, "tags": ["Tag795"]}]}.

    AC Setup User Expected Predicate / has_perm API (ObjectTagOrgView) Result
    AC1 Flag Force On authz_user (authz Course Staff only) Allowed True / True 200 Passed
    AC2 Flag Force On legacy_user (legacy staff only) Denied False / False 403 Passed
    AC3 Flag Force Off legacy_user (legacy staff) Allowed, unchanged True / True 200 Passed
    AC3 (control) Flag Force Off authz_user (no role yet) Denied False / False 403 Passed
    AC4 Course flag Force On, library object legacy_user (library author) Allowed, unchanged True / True 200 Passed
    AC4 Course flag Force On, library object authz_user (no library role) Denied, unchanged False / False 403 Passed
    AC5 All rows above Both users Studio's own view and the direct has_perm check agree Agreed in all 12 checks Agreed in all 12 checks Passed

    The unit tests added by the PR (TestRulesCourseAuthzPermissions, 5 tests) also passed locally.

    Additionally tested

    • A section inside the course, not only the course key itself. It resolved to the same course-level authz check in every case.
    • AC3 and AC4, which the developer's notes do not cover.

    Observation: changed 403 message

    For legacy_user on a course with the flag Force On, the 403 response text changed. It used to be You do not have permission to manage object tags. and is now You do not have permission to change object tags for Taxonomy: .... This follows from the PR removing the view's own ensure_user_has_can_tag_object_permissions override, 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

    ObjectTagOrgView already 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 and has_perm columns.

    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:

    1. The fix changes a permission predicate, not an endpoint. The only way to call has_perm('oel_tagging.can_tag_object', ...) directly today is from a Django shell, which is what the developer's notes use.
    2. The only existing REST endpoint in this area, 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.
    3. The first endpoint that calls oel_tagging.can_tag_object directly, 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 as object_id). The setup uses the same data shape as above: a course with the authz.enable_course_authoring override set in Django admin, and users added through the Studio Course Team page.

    • AC1: a user with the authz Course Staff role only, on a course with the flag Force On, can create a criterion (expected 201).
    • AC2: a user with a legacy staff role only, on the same course, is denied (expected 403).
    • AC3: a user with a legacy staff role on a course with the flag off can create a criterion. The positive scenarios in [BE] Create CompetencyCriteria #665's own test plan cover this, so no separate check is planned.
    • AC5: for the same user and section, the Studio object tags endpoint and the [BE] Create CompetencyCriteria #665 endpoint return the same outcome (both allowed or both denied).

    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.

  8. dvarenikqaconsultant commented on Oct 19, 2026

    @dvarenikqaconsultant

    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 endpoint POST /api/cbe/v1/competencies/<tag_id>/criteria/, sent from an authenticated browser session
    Implementation 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:

    1. Legacy role only. test670 was 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.
    2. Org-level role only. The course-level legacy roles were removed in Django admin, and test670 got an org-level staff role for org QA795.
    3. authz role. The org-level role was removed. With the flag on, test670 got Course Staff on course A through the Admin Console, so the role exists only in openedx-authz.
    4. Library role. test670 got Library Author on lib:QA795:lib795 through the Admin Console.

    Acceptance criteria

    # Scenario Request Expected Actual Result
    AC1 User has an authz role (Course Staff) on course A, flag on POST criteria, object_id = A1 201 201. The body has id, group_id, object_tag_id, rule_profile_id; object_id and logic_operator are not returned ✅
    AC2 User has only a legacy Staff role on course A, flag on POST criteria, object_id = A2 403 403 ✅
    AC3 User has a legacy Staff role on course B, flag off POST criteria, object_id = B1 201 201 ✅
    AC4 Content Library component Studio PUT on lb:QA795:lib795:problem:p795 403 without a library role, 200 with one 403 without a role, 200 with Library Author ✅
    AC5 Studio path and #665 path give the same answer See the table below Same outcome on both paths Same outcome on both paths ✅

    AC5 details:

    User / object Studio PUT POST criteria (#665)
    authz role, course A (flag on), subsection A2 200 201
    Legacy role only, course A (flag on), subsection A2 403 403
    Legacy role, course B (flag off), subsection B1 200 201

    Additionally tested

    • Org admin on a course with the flag on. With only an org-level staff role 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.
    • Object inside a course. With only a legacy role, POST criteria with a unit of course A (block-v1:QA795+A+2026+type@vertical+block@a1_unit) returned 403. Objects inside a course are checked at the course level.
    • Studio 403 message. With only a legacy role on course A (flag on), Studio PUT on A2 returned 403 with the message "You do not have permission to change object tags for Taxonomy: (145) QA Test Taxonomy or Object: block-v1:QA795+A+2026+type@sequential+block@a2." The status code is unchanged, but the message is different from the earlier "You do not have permission to manage object tags." This is expected after the duplicated view check was removed, and it does not affect any AC.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions