-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
1 parent
96f6349
commit d672110
Showing
14 changed files
with
718 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
""" | ||
Signal/event handlers for content search | ||
""" | ||
from django.db.models.signals import post_delete | ||
from django.dispatch import receiver | ||
from openedx_events.content_authoring.data import ContentLibraryData | ||
from openedx_events.content_authoring.signals import CONTENT_LIBRARY_DELETED | ||
|
||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview | ||
from openedx.core.djangoapps.content.search.models import SearchAccess | ||
|
||
|
||
# Using post_delete here because there is no COURSE_DELETED event defined. | ||
@receiver(post_delete, sender=CourseOverview) | ||
def delete_course_search_access(sender, instance, **kwargs): # pylint: disable=unused-argument | ||
"""Deletes the SearchAccess instance for deleted CourseOverview""" | ||
SearchAccess.objects.filter(context_key=instance.id).delete() | ||
|
||
|
||
@receiver(CONTENT_LIBRARY_DELETED) | ||
def delete_library_search_access(content_library: ContentLibraryData, **kwargs): | ||
"""Deletes the SearchAccess instance for deleted content libraries""" | ||
SearchAccess.objects.filter(context_key=content_library.library_key).delete() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
openedx/core/djangoapps/content/search/migrations/0001_initial.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Generated by Django 4.2.10 on 2024-04-02 04:55 | ||
|
||
from django.db import migrations, models | ||
from opaque_keys.edx.django.models import LearningContextKeyField | ||
from opaque_keys.edx.locator import LibraryLocatorV2 | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [ | ||
('course_overviews', '0001_initial'), | ||
('content_libraries', '0001_initial'), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name='SearchAccess', | ||
fields=[ | ||
('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)), | ||
('context_key', LearningContextKeyField(max_length=255, unique=True)), | ||
], | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
"""Database models for content search""" | ||
|
||
from __future__ import annotations | ||
|
||
from django.db import models | ||
from django.utils.translation import gettext_lazy as _ | ||
from opaque_keys.edx.django.models import LearningContextKeyField | ||
from rest_framework.request import Request | ||
|
||
from common.djangoapps.student.role_helpers import get_course_roles | ||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole | ||
from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user | ||
|
||
|
||
class SearchAccess(models.Model): | ||
""" | ||
Stores a numeric ID for each ContextKey. | ||
We use this shorter ID instead of the full ContextKey when determining a user's access to search-indexed course and | ||
library content because: | ||
a) in some deployments, users may be granted access to more than 1_000 individual courses, and | ||
b) the search filter request is stored in the JWT, which is limited to 8Kib. | ||
""" | ||
id = models.BigAutoField( | ||
primary_key=True, | ||
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." | ||
), | ||
) | ||
context_key = LearningContextKeyField( | ||
max_length=255, unique=True, null=False, | ||
) | ||
|
||
|
||
def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) -> list[int]: | ||
""" | ||
Returns a list of SearchAccess.id values for courses and content libraries that the requesting user has been | ||
individually grated access to. | ||
Omits any courses/libraries with orgs in the `omit_orgs` list. | ||
""" | ||
omit_orgs = omit_orgs or [] | ||
|
||
course_roles = get_course_roles(request.user) | ||
course_clause = models.Q(context_key__in=[ | ||
role.course_id | ||
for role in course_roles | ||
if ( | ||
role.role in [CourseInstructorRole.ROLE, CourseStaffRole.ROLE] | ||
and role.org not in omit_orgs | ||
) | ||
]) | ||
|
||
libraries = get_libraries_for_user(user=request.user) | ||
library_clause = models.Q(context_key__in=[ | ||
lib.library_key for lib in libraries | ||
if lib.library_key.org not in omit_orgs | ||
]) | ||
|
||
# Sort by descending access ID to simulate prioritizing the "most recently created context keys". | ||
return list( | ||
SearchAccess.objects.filter( | ||
course_clause | library_clause | ||
).order_by('-id').values_list("id", flat=True) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.