This is an attempt to fix a performance problem on the libraries home page. When you go to studio home and click on the libraries tab, on prod it will be quick for admins but extremely slow for course instructors (> 12 seconds) and leads to timeouts. It grows with the number of libraries that are assigned to the instructor.
The Python code for the request to load libraries for a particular user goes through all existing libraries and then checks all of the user's roles for each library, which results in a complexity of O(l*r), l=libraries, r=roles. This PR improves the complexity to O(l).
The BulkRoleCache and RoleCache classes were using a python set to store all roles for a particular user. A user can have a large number of roles, and lookup speed of iterating through a set is slow (O(n)). Most roles don't have the same course id, however. So if you have the course id of the role you're looking for, we can use a dict of course ids that contain related roles. The number of roles per course id is negligible, so we arrive at a lookup speed of O(1) when looking up a user's roles that belong to a specific course id.
The BulkRoleCache now caches and stores user roles in a data structure like this:
{
user_id_1: {
course_id_1: {role1, role2, role3}, # Set of roles associated with course_id_1
course_id_2: {role4, role5, role6}, # Set of roles associated with course_id_2
[ROLE_CACHE_UNGROUPED_ROLES_KEY]: {role7, role8} # Set of roles not tied to any specific course or library. For example, Global Staff roles.
},
user_id_2: { ... } # Similar structure for another user
}
While this changes the data structure used to store roles under the hood and adds the new property `roles_by_course_id` to the RoleCache,
when initializing the RoleCache will store roles additionally in the previous data structure - as a flat set - in the `_roles` property accessible via `all_roles_set`. This establishes
backwards compatibility.
We are now storing roles twice in the RoleCache (in each of the two data structures), which means this takes twice as much memory, but only in the scope of a request.
240 lines
8.9 KiB
Python
240 lines
8.9 KiB
Python
"""
|
|
The application interface to roles which checks whether any user trying to change
|
|
authorization has authorization to do so, which infers authorization via role hierarchy
|
|
(GlobalStaff is superset of auths of course instructor, ...), which consults the config
|
|
to decide whether to check course creator role, and other such functions.
|
|
"""
|
|
|
|
|
|
from ccx_keys.locator import CCXBlockUsageLocator, CCXLocator
|
|
from django.conf import settings
|
|
from django.core.exceptions import PermissionDenied
|
|
from opaque_keys.edx.locator import LibraryLocator
|
|
|
|
from common.djangoapps.student.roles import (
|
|
CourseBetaTesterRole,
|
|
CourseCreatorRole,
|
|
CourseInstructorRole,
|
|
CourseLimitedStaffRole,
|
|
CourseRole,
|
|
CourseStaffRole,
|
|
GlobalStaff,
|
|
LibraryUserRole,
|
|
OrgContentCreatorRole,
|
|
OrgInstructorRole,
|
|
OrgLibraryUserRole,
|
|
OrgStaffRole,
|
|
)
|
|
|
|
# Studio permissions:
|
|
STUDIO_EDIT_ROLES = 8
|
|
STUDIO_VIEW_USERS = 4
|
|
STUDIO_EDIT_CONTENT = 2
|
|
STUDIO_VIEW_CONTENT = 1
|
|
STUDIO_NO_PERMISSIONS = 0
|
|
# In addition to the above, one is always allowed to "demote" oneself to a lower role within a course, or remove oneself
|
|
|
|
|
|
def is_ccx_course(course_key):
|
|
"""
|
|
Check whether the course locator maps to a CCX course; this is important
|
|
because we don't allow access to CCX courses in Studio.
|
|
"""
|
|
return isinstance(course_key, CCXLocator) or isinstance(course_key, CCXBlockUsageLocator) # lint-amnesty, pylint: disable=consider-merging-isinstance
|
|
|
|
|
|
def user_has_role(user, role):
|
|
"""
|
|
Check whether this user has access to this role (either direct or implied)
|
|
:param user:
|
|
:param role: an AccessRole
|
|
"""
|
|
if not user.is_active:
|
|
return False
|
|
# Do cheapest check first even though it's not the direct one
|
|
if GlobalStaff().has_user(user):
|
|
return True
|
|
# CourseCreator is odd b/c it can be disabled via config
|
|
if isinstance(role, CourseCreatorRole):
|
|
# completely shut down course creation setting
|
|
if settings.FEATURES.get('DISABLE_COURSE_CREATION', False):
|
|
return False
|
|
# wide open course creation setting
|
|
if not settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
|
return True
|
|
|
|
if role.has_user(user):
|
|
return True
|
|
# If not, then check inferred permissions
|
|
if (isinstance(role, (CourseStaffRole, CourseBetaTesterRole)) and
|
|
CourseInstructorRole(role.course_key).has_user(user)):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def get_user_permissions(user, course_key, org=None):
|
|
"""
|
|
Get the bitmask of permissions that this user has in the given course context.
|
|
Can also set course_key=None and pass in an org to get the user's
|
|
permissions for that organization as a whole.
|
|
"""
|
|
if org is None:
|
|
org = course_key.org
|
|
course_key = course_key.for_branch(None)
|
|
else:
|
|
assert course_key is None
|
|
# No one has studio permissions for CCX courses
|
|
if is_ccx_course(course_key):
|
|
return STUDIO_NO_PERMISSIONS
|
|
all_perms = STUDIO_EDIT_ROLES | STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
|
|
# global staff, org instructors, and course instructors have all permissions:
|
|
if GlobalStaff().has_user(user) or OrgInstructorRole(org=org).has_user(user):
|
|
return all_perms
|
|
if course_key and user_has_role(user, CourseInstructorRole(course_key)):
|
|
return all_perms
|
|
# HACK: Limited Staff should not have studio read access. However, since many LMS views depend on the
|
|
# `has_course_author_access` check and `course_author_access_required` decorator, we have to allow write access
|
|
# until the permissions become more granular. For example, there could be STUDIO_VIEW_COHORTS and
|
|
# STUDIO_EDIT_COHORTS specifically for the cohorts endpoint, which is used to display the "Cohorts" tab of the
|
|
# Instructor Dashboard.
|
|
# The permissions matrix from the RBAC project (https://github.com/openedx/platform-roadmap/issues/246) shows that
|
|
# the LMS and Studio permissions will be separated as a part of this project. Once this is done (and this code is
|
|
# not removed during its implementation), we can replace the Limited Staff permissions with more granular ones.
|
|
if course_key and user_has_role(user, CourseLimitedStaffRole(course_key)):
|
|
return STUDIO_EDIT_CONTENT
|
|
# Staff have all permissions except EDIT_ROLES:
|
|
if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))):
|
|
return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
|
|
|
|
# Otherwise, for libraries, users can view only:
|
|
if course_key and isinstance(course_key, LibraryLocator):
|
|
if OrgLibraryUserRole(org=org).has_user(user) or user_has_role(user, LibraryUserRole(course_key)):
|
|
return STUDIO_VIEW_USERS | STUDIO_VIEW_CONTENT
|
|
return STUDIO_NO_PERMISSIONS
|
|
|
|
|
|
def has_studio_write_access(user, course_key):
|
|
"""
|
|
Return True if user has studio write access to the given course.
|
|
Note that the CMS permissions model is with respect to courses.
|
|
There is a super-admin permissions if user.is_staff is set.
|
|
Also, since we're unifying the user database between LMS and CAS,
|
|
I'm presuming that the course instructor (formally known as admin)
|
|
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our
|
|
queries here as INSTRUCTOR has all the rights that STAFF do.
|
|
|
|
:param user:
|
|
:param course_key: a CourseKey
|
|
"""
|
|
return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key))
|
|
|
|
|
|
def has_course_author_access(user, course_key):
|
|
"""
|
|
Old name for has_studio_write_access
|
|
"""
|
|
return has_studio_write_access(user, course_key)
|
|
|
|
|
|
def has_studio_advanced_settings_access(user):
|
|
"""
|
|
If DISABLE_ADVANCED_SETTINGS feature is enabled, only Django Superuser
|
|
or Django Staff can access "Advanced Settings".
|
|
|
|
By default, this feature is disabled.
|
|
"""
|
|
return (
|
|
not settings.FEATURES.get('DISABLE_ADVANCED_SETTINGS', False)
|
|
or user.is_staff
|
|
or user.is_superuser
|
|
)
|
|
|
|
|
|
def has_studio_read_access(user, course_key):
|
|
"""
|
|
Return True if user is allowed to view this course/library in studio.
|
|
Will also return True if user has write access in studio (has_course_author_access)
|
|
|
|
There is currently no such thing as read-only course access in studio, but
|
|
there is read-only access to content libraries.
|
|
"""
|
|
return bool(STUDIO_VIEW_CONTENT & get_user_permissions(user, course_key))
|
|
|
|
|
|
def is_content_creator(user, org):
|
|
"""
|
|
Check if the user has the role to create content.
|
|
|
|
This function checks if the User has role to create content
|
|
or if the org is supplied, it checks for Org level course content
|
|
creator.
|
|
"""
|
|
return (user_has_role(user, CourseCreatorRole()) or
|
|
user_has_role(user, OrgContentCreatorRole(org=org)))
|
|
|
|
|
|
def add_users(caller, role, *users):
|
|
"""
|
|
The caller requests adding the given users to the role. Checks that the caller
|
|
has sufficient authority.
|
|
|
|
:param caller: a user
|
|
:param role: an AccessRole
|
|
"""
|
|
_check_caller_authority(caller, role)
|
|
role.add_users(*users)
|
|
|
|
|
|
def remove_users(caller, role, *users):
|
|
"""
|
|
The caller requests removing the given users from the role. Checks that the caller
|
|
has sufficient authority.
|
|
|
|
:param caller: a user
|
|
:param role: an AccessRole
|
|
"""
|
|
# can always remove self (at this layer)
|
|
if not(len(users) == 1 and caller == users[0]):
|
|
_check_caller_authority(caller, role)
|
|
role.remove_users(*users)
|
|
|
|
|
|
def update_org_role(caller, role, user, orgs):
|
|
"""
|
|
The caller requests updating the Org role for the user. Checks that the caller has
|
|
sufficient authority.
|
|
|
|
:param caller: an user
|
|
:param role: an AccessRole class
|
|
:param user: an user for which org roles are updated
|
|
:param orgs: List of organization names to update the org role
|
|
"""
|
|
_check_caller_authority(caller, role())
|
|
existing_org_roles = set(role().get_orgs_for_user(user))
|
|
orgs_roles_to_create = list(set(orgs) - existing_org_roles)
|
|
org_roles_to_delete = list(existing_org_roles - set(orgs))
|
|
for org in orgs_roles_to_create:
|
|
role(org=org).add_users(user)
|
|
for org in org_roles_to_delete:
|
|
role(org=org).remove_users(user)
|
|
|
|
|
|
def _check_caller_authority(caller, role):
|
|
"""
|
|
Internal function to check whether the caller has authority to manipulate this role
|
|
:param caller: a user
|
|
:param role: an AccessRole
|
|
"""
|
|
if not (caller.is_authenticated and caller.is_active):
|
|
raise PermissionDenied
|
|
# superuser
|
|
if GlobalStaff().has_user(caller):
|
|
return
|
|
|
|
if isinstance(role, (GlobalStaff, CourseCreatorRole, OrgContentCreatorRole)): # lint-amnesty, pylint: disable=no-else-raise
|
|
raise PermissionDenied
|
|
elif isinstance(role, CourseRole): # instructors can change the roles w/in their course
|
|
if not user_has_role(caller, CourseInstructorRole(role.course_key)):
|
|
raise PermissionDenied
|