Repository navigation
Spike: State of the Art of the Instructor Dashboard #388
Description
Activity
Summary of the current state of the art, organized around this issue's questions.
1. How legacy permissions/roles are handled today
The Instructor Dashboard's permission model is bridgekeeper (
lms/djangoapps/instructor/permissions.py), e.g.perms[VIEW_DASHBOARD] = HasRolesRule('staff', 'instructor', 'data_researcher') | HasAccessRule('staff') | HasAccessRule('instructor'), checked viarequest.user.has_perm(permission, course).HasAccessRulecallshas_access(user, action, course)(lms/djangoapps/courseware/access.py), andHasRolesRulecallsCourseRole(role, course_key).has_user(user)/OrgRole(role, org).has_user(user)(lms/djangoapps/courseware/rules.py:158-179). Both bottom out inRoleCache(common/djangoapps/student/roles.py:343), which merges legacyCourseAccessRolerows with AuthZ assignments viaget_authz_compat_course_access_roles_for_user, unconditionally, regardless ofauthz.enable_course_authoring.All 6 legacy roles the dashboard checks (
staff,limited_staff,instructor,beta_testers,data_researcher,ccx_coach, seelms/djangoapps/instructor/access.py::ROLES) have an AuthZ equivalent inLEGACY_COURSE_ROLE_EQUIVALENCES. Single-user permission checks in the Instructor Dashboard already work transparently with AuthZ assignments, with no dashboard-specific code needed.2. Bulk data access: a real gap, not just an edge case
There are two structurally different ways the codebase reads roles, and they behave differently:
-
Per-user checks (
has_user->RoleCache) always merge both systems. -
Bulk/queryset-based reads run a raw, legacy-only
CourseAccessRolequery and never look at AuthZ. This pattern shows up in two unrelated places:lms/djangoapps/grades/rest_api/v1/gradebook_views.py:648-660: the Gradebook API'sexcluded_course_rolesfilter, used to hide staff/beta-testers from the gradebook, runsCourseAccessRole.objects.filter(user=OuterRef('user'), course_id=course_key, role__in=excluded_course_roles)directly.lms/djangoapps/courseware/rules.py:139-154,HasStaffAccessToContent.query(), registered asperms[MASQUERADE_AS_STUDENT]incourseware/permissions.py:16. This class also has acheck()method behind alaboratory.ExperimentA/B shim that safely useshas_accessas the control, butquery(), used when the permission filters a queryset instead of checking one object, has no such safety net and returns the raw, AuthZ-blind Q object directly.
Both trace back to the same root cause:
users_with_role(),get_orgs_for_user(), andhas_org_for_user()(common/djangoapps/student/roles.py:444-471,:600-639) branch exclusively onenable_authz_course_authoring(course_key, role)instead of merging both sources the wayRoleCachedoes.list_with_level()(lms/djangoapps/instructor/access.py:93-101), which backs the Instructor Dashboard's course team listing endpoint (GET /api/instructor/v2/courses/{courseId}/teaminapi_v2.py), goes throughusers_with_role()and inherits the same gap: a course with AuthZ-only team members would show an incomplete team list in the dashboard today. -
This isn't hypothetical on the write side either:
add_users()(common/djangoapps/student/roles.py:550-557) is a strictif/elsebetween_authz_add_users(:514-528, writes only to AuthZ) and_legacy_add_users(:532-545, writes only toCourseAccessRole), never both. Once a role is granted through the AuthZ path, there is no correspondingCourseAccessRolerow. -
It's also not limited to new grants under a flipped flag. Toggling
authz.enable_course_authoringfor an existing course/org actively migrates existingCourseAccessRolerows into AuthZ and deletes them (openedx_authz/handlers.py:106handle_course_waffle_flag_change,:122handle_org_waffle_flag_change, both callrun_course_authoring_migration(..., delete_after_migration=True)). The moment a course adopts AuthZ course authoring, any rawCourseAccessRolebulk lookup for that course silently stops seeing people who used to be there,excluded_course_rolesin the gradebook would stop excluding staff/beta accounts with no new grant required to trigger it. -
There's a second, independent way this can happen without the flag ever being touched for a given course, see point 5.
BulkRoleCache.prefetch(users)(common/djangoapps/student/roles.py:282-338, used fromgradebook_views.py:91andinstructor_task/tasks_helper/grades.py:275) is the one bulk path that does merge correctly, it loops per user and callsget_authz_compat_course_access_roles_for_user. That's a per-user AuthZ/Casbin call inside a loop with no batching, worth watching for large courses.3. The
authz.enable_course_authoringflag: no new flag needed, but the flag alone isn't a reliable signalThe flag isn't checked at all inside
get_authz_compat_course_access_roles_for_user, so per-user permission checks are flag-independent by construction. It only gates: (a)add_users/remove_users, which system a new grant lands in, and (b)users_with_role/get_orgs_for_user/has_org_for_user, which system a bulk read looks at.As point 5 shows, the flag's state for a course doesn't actually guarantee where that course's role data lives, since the Admin Console writes to AuthZ regardless of the course's flag state. A correct fix has to merge both sources unconditionally, the same way
RoleCachealready does. Branching on the flag, even via a new instructor-dashboard-specific flag, would just relocate the same class of bug. A new flag doesn't seem like the right lever here.4. How the MFE validates permissions
The Instructor Dashboard MFE has no client-side authorization logic of its own. On load,
useCourseInfo(src/data/apiHook.ts) callsgetCourseInfo(src/data/api.ts), which hitsGET /api/instructor/v2/courses/{courseId}, backed byCourseMetadataView(lms/djangoapps/instructor/views/api_v2.py:178,permission_classes = (IsAuthenticated, InstructorPermission),permission_name = VIEW_DASHBOARD). A 401/403 from that call is caught byAccessErrorObserver(src/providers/AccessErrorObserver.tsx) and routes the user to a forbidden/unauthorized page, that's the entire gate for whether the user can see the dashboard at all.Beyond that gate, the response includes a
permissionsobject (admin,instructor,finance_admin,sales_admin,staff,forum_admin,data_researcher) that the MFE uses to decide what to render (tabs, action buttons). Every field in that object is built viaCourseXRole(course_key).has_user(user)oruser.has_perm(...)(CourseInformationSerializerV2.get_permissions(),lms/djangoapps/instructor/views/serializers_v2.py:424-435), the sameRoleCache-backed, AuthZ-merged path as everything else. No field in that payload runs a rawCourseAccessRolequery. The one payload the MFE depends on for capability-based rendering is already fully AuthZ-aware.Minor adjacent note:
finance_adminandsales_adminhave no entry inLEGACY_COURSE_ROLE_EQUIVALENCES, they've never been migrated to AuthZ. Not a bug,RoleCache's legacy loop reads allCourseAccessRolerows unconditionally regardless of equivalence, so these two keep working as before. They're simply out of scope for AuthZ migration until someone adds them to the equivalence table.5. Admin Console vs. Instructor Dashboard
The Admin Console talks directly to openedx-authz's own REST API (
/api/authz/v1/roles/users/,/api/authz/v1/assignments/, etc.,frontend-app-admin-console/src/authz-module/data/api.ts:126-222), not throughcommon/djangoapps/student/roles.py. On the server side, the only placeAUTHZ_COURSE_AUTHORING_FLAGappears inopenedx_authz/rest_api/v1/views.pyis a read-onlywaffle-flag-statesendpoint (views.py:1401-1425) that reports flag state for display purposes, it doesn't gate the role-assignment endpoints themselves.So the Admin Console can grant an AuthZ role for a course whose
authz.enable_course_authoringflag is off. Permission checks still work correctly afterward, sinceRoleCachemerges unconditionally, but any of the bulk/raw-query code from point 2 (gradebook exclusion, masquerade queryset filtering, course team listing) will silently miss that grant, without the course ever having "migrated" in the flag sense.Net conclusion on responsibilities: the Admin Console is AuthZ-only and flag-agnostic by design. The Instructor Dashboard's own team-management endpoints (
api_v2.py,CourseTeamPermission, callingallow_access/revoke_access->CourseRole.add_users()/remove_users()) are flag-aware and write to legacy-or-authz depending on the course. Both are legitimate ways to grant course team roles today, they aren't fully consolidated into the Admin Console, which is exactly why a read path that assumes "the flag tells you where the data is" can't be correct in general.6. Proposed reusable pattern
RoleCache's merge-both-sources approach is already the right pattern, the fix is to stop reinventing an exclusive-branch version of it in multiple places:- Rewrite
users_with_role(),get_orgs_for_user(), andhas_org_for_user()to merge legacy and AuthZ results, matchingRoleCache, instead of branching onenable_authz_course_authoring. This alone fixes the course-team-listing gap. - Audit and fix the raw
CourseAccessRolebulk queries identified directly (gradebook_views.py'sexcluded_course_rolesfilter,HasStaffAccessToContent.query()), since a fix to (1) won't reach code that queries the model directly instead of going through the role classes. Worth a broader repo-wide search for other directtCourseAccessRole.objectsbulk queries before considering this closed, this pass covered the instructor, grades, and courseware apps in depth but not the full codebase. BulkRoleCache's per-user AuthZ loop is correct but not batched. If it becomes a measurable bottleneck, a batched read from openedx-authz (assignments for many users in one scope, one call) would avoid the N-calls cost.- None of this needs a new waffle flag. The read side needs to stop trusting the flag as a signal for where data lives, since point 5 shows it isn't one.
-
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Description
authz.course_authoringfeature flag will behave and impact the current flow. Do we need a new flag for the instructor dashboard?