Skip to content

Spike: State of the Art of the Instructor Dashboard #388

Description

@BryanttV

Description

  • Research how the user access permissions and roles are handled for the Instructor Dashboard on the legacy system.
  • Understand how filtering and data access work for getting bulk objects (e.g., learner data, grades, enrollments) that an instructor has access to.
  • Determine how the authz.course_authoring feature flag will behave and impact the current flow. Do we need a new flag for the instructor dashboard?
  • Investigate how permissions are currently validated directly from the MFE.
  • Analyze the separation between the Admin Console and the Instructor Dashboard, clearly delimiting the operational responsibilities of each (LMS-based vs. Admin Console)
  • Propose a reusable solution or pattern we can use to ease further development and support both authz mechanisms within the dashboard.

Activity

  1. moved this to In Grooming in RBAC AuthZ Boardon Aug 13, 2026
  2. moved this from In Grooming to Ready for Development in RBAC AuthZ Boardon Aug 20, 2026
  3. efortish commented on Sep 2, 2026

    @efortish
    Contributor

    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 via request.user.has_perm(permission, course).

    HasAccessRule calls has_access(user, action, course) (lms/djangoapps/courseware/access.py), and HasRolesRule calls CourseRole(role, course_key).has_user(user) / OrgRole(role, org).has_user(user) (lms/djangoapps/courseware/rules.py:158-179). Both bottom out in RoleCache (common/djangoapps/student/roles.py:343), which merges legacy CourseAccessRole rows with AuthZ assignments via get_authz_compat_course_access_roles_for_user, unconditionally, regardless of authz.enable_course_authoring.

    All 6 legacy roles the dashboard checks (staff, limited_staff, instructor, beta_testers, data_researcher, ccx_coach, see lms/djangoapps/instructor/access.py::ROLES) have an AuthZ equivalent in LEGACY_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 CourseAccessRole query 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's excluded_course_roles filter, used to hide staff/beta-testers from the gradebook, runs CourseAccessRole.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 as perms[MASQUERADE_AS_STUDENT] in courseware/permissions.py:16. This class also has a check() method behind a laboratory.Experiment A/B shim that safely uses has_access as the control, but query(), 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(), and has_org_for_user() (common/djangoapps/student/roles.py:444-471, :600-639) branch exclusively on enable_authz_course_authoring(course_key, role) instead of merging both sources the way RoleCache does. 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}/team in api_v2.py), goes through users_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 strict if/else between _authz_add_users (:514-528, writes only to AuthZ) and _legacy_add_users (:532-545, writes only to CourseAccessRole), never both. Once a role is granted through the AuthZ path, there is no corresponding CourseAccessRole row.

    • It's also not limited to new grants under a flipped flag. Toggling authz.enable_course_authoring for an existing course/org actively migrates existing CourseAccessRole rows into AuthZ and deletes them (openedx_authz/handlers.py:106 handle_course_waffle_flag_change, :122 handle_org_waffle_flag_change, both call run_course_authoring_migration(..., delete_after_migration=True)). The moment a course adopts AuthZ course authoring, any raw CourseAccessRole bulk lookup for that course silently stops seeing people who used to be there, excluded_course_roles in 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 from gradebook_views.py:91 and instructor_task/tasks_helper/grades.py:275) is the one bulk path that does merge correctly, it loops per user and calls get_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_authoring flag: no new flag needed, but the flag alone isn't a reliable signal

    The 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 RoleCache already 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) calls getCourseInfo (src/data/api.ts), which hits GET /api/instructor/v2/courses/{courseId}, backed by CourseMetadataView (lms/djangoapps/instructor/views/api_v2.py:178, permission_classes = (IsAuthenticated, InstructorPermission), permission_name = VIEW_DASHBOARD). A 401/403 from that call is caught by AccessErrorObserver (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 permissions object (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 via CourseXRole(course_key).has_user(user) or user.has_perm(...) (CourseInformationSerializerV2.get_permissions(), lms/djangoapps/instructor/views/serializers_v2.py:424-435), the same RoleCache-backed, AuthZ-merged path as everything else. No field in that payload runs a raw CourseAccessRole query. The one payload the MFE depends on for capability-based rendering is already fully AuthZ-aware.

    Minor adjacent note: finance_admin and sales_admin have no entry in LEGACY_COURSE_ROLE_EQUIVALENCES, they've never been migrated to AuthZ. Not a bug, RoleCache's legacy loop reads all CourseAccessRole rows 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 through common/djangoapps/student/roles.py. On the server side, the only place AUTHZ_COURSE_AUTHORING_FLAG appears in openedx_authz/rest_api/v1/views.py is a read-only waffle-flag-states endpoint (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_authoring flag is off. Permission checks still work correctly afterward, since RoleCache merges 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, calling allow_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:

    1. Rewrite users_with_role(), get_orgs_for_user(), and has_org_for_user() to merge legacy and AuthZ results, matching RoleCache, instead of branching on enable_authz_course_authoring. This alone fixes the course-team-listing gap.
    2. Audit and fix the raw CourseAccessRole bulk queries identified directly (gradebook_views.py's excluded_course_roles filter, 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 directt CourseAccessRole.objects bulk queries before considering this closed, this pass covered the instructor, grades, and courseware apps in depth but not the full codebase.
    3. 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.
    4. 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.
  4. moved this from Ready for Development to Ready for Review in RBAC AuthZ Boardon Sep 2, 2026
  5. moved this from Ready for Review to Done in RBAC AuthZ Boardon Sep 8, 2026
  6. closed this as completedby moving to Done in RBAC AuthZ Boardon Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions