Skip to content

Commit d672110

Browse files
feat: restrict Studio search results based on user permissions (#34471)
* feat: adds SearchAccess model Stores a numeric ID for each course + library, which will generally be shorter than the full context_key, so we can pack more of them into the the Meilisearch search filter. Also: * Adds data migration pre-populates the SearchAccess model from the existing CourseOverview and ContentLibrary records * Adds signal handlers to add/remove SearchAccess entries when content is created or deleted. * Adds get_access_ids_for_request() helper method for use in views. * Adds tests. * test: can't import content.search in lms tests * feat: use SearchAccess in documents and views * Adds an access_id field to the document, which stores the SearchAccess.id for the block's context. * Use the requesting user's allowed access_ids to filter search results to documents with those access_ids. * Since some users have a lot of individual access granted, limit the number of access_ids in the filter to a large number (1_000) * Updates tests to demonstrate. * test: can't import content.search or content_staging in lms tests * fix: make access_id field filterable * fix: use SearchAccess.get_or_create in signal handlers In theory, we shouldn't have to do this, because the CREATE and DELETE events should keep the SearchAccess table up-to-date. But in practice, signals can be missed (or in tests, they may be disabled). So we assume that it's ok to re-use a SearchAccess.id created for a given course or library context_key. * refactor: refactors the view tests to make them clearer Uses helper methods and decorators to wrap the settings and patches used by multiple view tests. * feat: adds org filters to meilisearch filter * Uses content_tagging.rules.get_user_orgs to fetch the user's content-related orgs for use in the meilisearch filter. * Limits the number of orgs used to 1_000 to keep token size down * refactor: removes data migration Users should use the reindex_studio management command to populate SearchAccess. * refactor: adds functions to common.djangoapps.student.role_helpers to allow general access to the user's RoleCache without having to access private attributes of User or RoleCache. Related changes: * Moves some functionality from openedx.core.djangoapps.enrollments.data.get_user_roles to this new helper method. * Use these new helper method in content_tagging.rules * fix: get_access_ids_for_request only returns individual access instead of all course keys that the user can read. Org- and GlobalStaff access checks will handle the rest. * fix: use org-level permissions when generating search filter Also refactors tests to demonstrate this change for OrgStaff and OrgInstructor users. * refactor: remove SearchAccess creation signal handlers Lets SearchAccess entries be created on demand during search indexing. * feat: omit access_ids from the search filter that are covered by the user's org roles --------- Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
1 parent 96f6349 commit d672110

14 files changed

Lines changed: 718 additions & 59 deletions

File tree

common/djangoapps/student/role_helpers.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""
22
Helpers for student roles
33
"""
4+
from __future__ import annotations
45

6+
from django.contrib.auth import get_user_model
57

68
from openedx.core.djangoapps.django_comment_common.models import (
79
FORUM_ROLE_ADMINISTRATOR,
@@ -12,15 +14,20 @@
1214
)
1315
from openedx.core.lib.cache_utils import request_cached
1416
from common.djangoapps.student.roles import (
17+
CourseAccessRole,
1518
CourseBetaTesterRole,
1619
CourseInstructorRole,
1720
CourseStaffRole,
1821
GlobalStaff,
1922
OrgInstructorRole,
20-
OrgStaffRole
23+
OrgStaffRole,
24+
RoleCache,
2125
)
2226

2327

28+
User = get_user_model()
29+
30+
2431
@request_cached()
2532
def has_staff_roles(user, course_key):
2633
"""
@@ -40,3 +47,32 @@ def has_staff_roles(user, course_key):
4047
is_org_instructor, is_global_staff, has_forum_role]):
4148
return True
4249
return False
50+
51+
52+
@request_cached()
53+
def get_role_cache(user: User) -> RoleCache:
54+
"""
55+
Returns a populated RoleCache for the given user.
56+
57+
The returned RoleCache is also cached on the provided `user` to improve performance on future roles checks.
58+
59+
:param user: User
60+
:return: All roles for all courses that this user has access to.
61+
"""
62+
# pylint: disable=protected-access
63+
if not hasattr(user, '_roles'):
64+
user._roles = RoleCache(user)
65+
return user._roles
66+
67+
68+
@request_cached()
69+
def get_course_roles(user: User) -> list[CourseAccessRole]:
70+
"""
71+
Returns a list of all course-level roles that this user has.
72+
73+
:param user: User
74+
:return: All roles for all courses that this user has access to.
75+
"""
76+
# pylint: disable=protected-access
77+
role_cache = get_role_cache(user)
78+
return list(role_cache._roles)

common/djangoapps/student/tests/test_roles.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from opaque_keys.edx.keys import CourseKey
99

1010
from common.djangoapps.student.roles import (
11+
CourseAccessRole,
1112
CourseBetaTesterRole,
1213
CourseInstructorRole,
1314
CourseRole,
@@ -23,6 +24,7 @@
2324
OrgStaffRole,
2425
RoleCache
2526
)
27+
from common.djangoapps.student.role_helpers import get_course_roles, has_staff_roles
2628
from common.djangoapps.student.tests.factories import AnonymousUserFactory, InstructorFactory, StaffFactory, UserFactory
2729

2830

@@ -48,6 +50,32 @@ def test_global_staff(self):
4850
assert not GlobalStaff().has_user(self.course_instructor)
4951
assert GlobalStaff().has_user(self.global_staff)
5052

53+
def test_has_staff_roles(self):
54+
assert has_staff_roles(self.global_staff, self.course_key)
55+
assert has_staff_roles(self.course_staff, self.course_key)
56+
assert has_staff_roles(self.course_instructor, self.course_key)
57+
assert not has_staff_roles(self.student, self.course_key)
58+
59+
def test_get_course_roles(self):
60+
assert not list(get_course_roles(self.student))
61+
assert not list(get_course_roles(self.global_staff))
62+
assert list(get_course_roles(self.course_staff)) == [
63+
CourseAccessRole(
64+
user=self.course_staff,
65+
course_id=self.course_key,
66+
org=self.course_key.org,
67+
role=CourseStaffRole.ROLE,
68+
)
69+
]
70+
assert list(get_course_roles(self.course_instructor)) == [
71+
CourseAccessRole(
72+
user=self.course_instructor,
73+
course_id=self.course_key,
74+
org=self.course_key.org,
75+
role=CourseInstructorRole.ROLE,
76+
)
77+
]
78+
5179
def test_group_name_case_sensitive(self):
5280
uppercase_course_id = "ORG/COURSE/NAME"
5381
lowercase_course_id = uppercase_course_id.lower()

openedx/core/djangoapps/content/search/documents.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.utils.text import slugify
99
from opaque_keys.edx.keys import UsageKey, LearningContextKey
1010

11+
from openedx.core.djangoapps.content.search.models import SearchAccess
1112
from openedx.core.djangoapps.content_libraries import api as lib_api
1213
from openedx.core.djangoapps.content_tagging import api as tagging_api
1314
from openedx.core.djangoapps.xblock import api as xblock_api
@@ -29,6 +30,7 @@ class Fields:
2930
block_type = "block_type"
3031
context_key = "context_key"
3132
org = "org"
33+
access_id = "access_id" # .models.SearchAccess.id
3234
# breadcrumbs: an array of {"display_name": "..."} entries. First one is the name of the course/library itself.
3335
# After that is the name of any parent Section/Subsection/Unit/etc.
3436
# It's a list of dictionaries because for now we just include the name of each but in future we may add their IDs.
@@ -78,6 +80,14 @@ def _meili_id_from_opaque_key(usage_key: UsageKey) -> str:
7880
return slugify(str(usage_key)) + "-" + suffix
7981

8082

83+
def _meili_access_id_from_context_key(context_key: LearningContextKey) -> int:
84+
"""
85+
Retrieve the numeric access id for the given course/library context.
86+
"""
87+
access, _ = SearchAccess.objects.get_or_create(context_key=context_key)
88+
return access.id
89+
90+
8191
def _fields_from_block(block) -> dict:
8292
"""
8393
Given an XBlock instance, call its index_dictionary() method to load any
@@ -96,6 +106,7 @@ class implementation returns only:
96106
# This is called context_key so it's the same for courses and libraries
97107
Fields.context_key: str(block.usage_key.context_key), # same as lib_key
98108
Fields.org: str(block.usage_key.context_key.org),
109+
Fields.access_id: _meili_access_id_from_context_key(block.usage_key.context_key),
99110
Fields.breadcrumbs: []
100111
}
101112
# Get the breadcrumbs (course, section, subsection, etc.):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Signal/event handlers for content search
3+
"""
4+
from django.db.models.signals import post_delete
5+
from django.dispatch import receiver
6+
from openedx_events.content_authoring.data import ContentLibraryData
7+
from openedx_events.content_authoring.signals import CONTENT_LIBRARY_DELETED
8+
9+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
10+
from openedx.core.djangoapps.content.search.models import SearchAccess
11+
12+
13+
# Using post_delete here because there is no COURSE_DELETED event defined.
14+
@receiver(post_delete, sender=CourseOverview)
15+
def delete_course_search_access(sender, instance, **kwargs): # pylint: disable=unused-argument
16+
"""Deletes the SearchAccess instance for deleted CourseOverview"""
17+
SearchAccess.objects.filter(context_key=instance.id).delete()
18+
19+
20+
@receiver(CONTENT_LIBRARY_DELETED)
21+
def delete_library_search_access(content_library: ContentLibraryData, **kwargs):
22+
"""Deletes the SearchAccess instance for deleted content libraries"""
23+
SearchAccess.objects.filter(context_key=content_library.library_key).delete()

openedx/core/djangoapps/content/search/management/commands/reindex_studio.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def handle(self, *args, **options):
8484
Fields.org,
8585
Fields.tags,
8686
Fields.type,
87+
Fields.access_id,
8788
])
8889
# Mark which attributes are used for keyword search, in order of importance:
8990
client.index(temp_index_name).update_searchable_attributes([
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 4.2.10 on 2024-04-02 04:55
2+
3+
from django.db import migrations, models
4+
from opaque_keys.edx.django.models import LearningContextKeyField
5+
from opaque_keys.edx.locator import LibraryLocatorV2
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
('course_overviews', '0001_initial'),
14+
('content_libraries', '0001_initial'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='SearchAccess',
20+
fields=[
21+
('id', models.BigAutoField(help_text='Numeric ID for each Course / Library context. This ID will generally require fewer bits than the full LearningContextKey, allowing more courses and libraries to be represented in content search filters.', primary_key=True, serialize=False)),
22+
('context_key', LearningContextKeyField(max_length=255, unique=True)),
23+
],
24+
),
25+
]

openedx/core/djangoapps/content/search/migrations/__init__.py

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Database models for content search"""
2+
3+
from __future__ import annotations
4+
5+
from django.db import models
6+
from django.utils.translation import gettext_lazy as _
7+
from opaque_keys.edx.django.models import LearningContextKeyField
8+
from rest_framework.request import Request
9+
10+
from common.djangoapps.student.role_helpers import get_course_roles
11+
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
12+
from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user
13+
14+
15+
class SearchAccess(models.Model):
16+
"""
17+
Stores a numeric ID for each ContextKey.
18+
19+
We use this shorter ID instead of the full ContextKey when determining a user's access to search-indexed course and
20+
library content because:
21+
22+
a) in some deployments, users may be granted access to more than 1_000 individual courses, and
23+
b) the search filter request is stored in the JWT, which is limited to 8Kib.
24+
"""
25+
id = models.BigAutoField(
26+
primary_key=True,
27+
help_text=_(
28+
"Numeric ID for each Course / Library context. This ID will generally require fewer bits than the full "
29+
"LearningContextKey, allowing more courses and libraries to be represented in content search filters."
30+
),
31+
)
32+
context_key = LearningContextKeyField(
33+
max_length=255, unique=True, null=False,
34+
)
35+
36+
37+
def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) -> list[int]:
38+
"""
39+
Returns a list of SearchAccess.id values for courses and content libraries that the requesting user has been
40+
individually grated access to.
41+
42+
Omits any courses/libraries with orgs in the `omit_orgs` list.
43+
"""
44+
omit_orgs = omit_orgs or []
45+
46+
course_roles = get_course_roles(request.user)
47+
course_clause = models.Q(context_key__in=[
48+
role.course_id
49+
for role in course_roles
50+
if (
51+
role.role in [CourseInstructorRole.ROLE, CourseStaffRole.ROLE]
52+
and role.org not in omit_orgs
53+
)
54+
])
55+
56+
libraries = get_libraries_for_user(user=request.user)
57+
library_clause = models.Q(context_key__in=[
58+
lib.library_key for lib in libraries
59+
if lib.library_key.org not in omit_orgs
60+
])
61+
62+
# Sort by descending access ID to simulate prioritizing the "most recently created context keys".
63+
return list(
64+
SearchAccess.objects.filter(
65+
course_clause | library_clause
66+
).order_by('-id').values_list("id", flat=True)
67+
)

openedx/core/djangoapps/content/search/tests/test_documents.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
99
from xmodule.modulestore.tests.factories import BlockFactory, ToyCourseFactory
1010

11-
from ..documents import searchable_doc_for_course_block
11+
try:
12+
# This import errors in the lms because content.search is not an installed app there.
13+
from ..documents import searchable_doc_for_course_block
14+
from ..models import SearchAccess
15+
except RuntimeError:
16+
searchable_doc_for_course_block = lambda x: x
17+
SearchAccess = {}
18+
1219

1320
STUDIO_SEARCH_ENDPOINT_URL = "/api/content_search/v2/studio/"
1421

@@ -26,6 +33,7 @@ def setUpClass(cls):
2633
cls.store = modulestore()
2734
cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py
2835
cls.toy_course_key = cls.toy_course.id
36+
2937
# Get references to some blocks in the toy course
3038
cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto")
3139
# Create a problem in library
@@ -55,6 +63,16 @@ def setUpClass(cls):
5563
tagging_api.tag_object(str(cls.html_block_key), cls.subject_tags, tags=["Chinese", "Jump Links"])
5664
tagging_api.tag_object(str(cls.html_block_key), cls.difficulty_tags, tags=["Normal"])
5765

66+
@property
67+
def toy_course_access_id(self):
68+
"""
69+
Returns the SearchAccess.id created for the toy course.
70+
71+
This SearchAccess object is created when documents are added to the search index, so this method must be called
72+
after this step, or risk a DoesNotExist error.
73+
"""
74+
return SearchAccess.objects.get(context_key=self.toy_course_key).id
75+
5876
def test_problem_block(self):
5977
"""
6078
Test how a problem block gets represented in the search index
@@ -71,6 +89,7 @@ def test_problem_block(self):
7189
"block_id": "Test_Problem",
7290
"context_key": "course-v1:edX+toy+2012_Fall",
7391
"org": "edX",
92+
"access_id": self.toy_course_access_id,
7493
"display_name": "Test Problem",
7594
"breadcrumbs": [
7695
{"display_name": "Toy Course"},
@@ -105,6 +124,7 @@ def test_html_block(self):
105124
"block_id": "toyjumpto",
106125
"context_key": "course-v1:edX+toy+2012_Fall",
107126
"org": "edX",
127+
"access_id": self.toy_course_access_id,
108128
"display_name": "Text",
109129
"breadcrumbs": [
110130
{"display_name": "Toy Course"},
@@ -139,6 +159,7 @@ def test_video_block_untagged(self):
139159
"block_id": "Welcome",
140160
"context_key": "course-v1:edX+toy+2012_Fall",
141161
"org": "edX",
162+
"access_id": self.toy_course_access_id,
142163
"display_name": "Welcome",
143164
"breadcrumbs": [
144165
{"display_name": "Toy Course"},

0 commit comments

Comments
 (0)