Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions openedx/core/djangoapps/content/search/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@
INDEX_SEARCHABLE_ATTRIBUTES,
INDEX_SORTABLE_ATTRIBUTES,
)
from openedx.core.djangoapps.content.search.models import IncrementalIndexCompleted, get_access_ids_for_request
from openedx.core.djangoapps.content.search.models import (
IncrementalIndexCompleted,
get_access_ids_for_request,
get_authz_org_keys,
)
from openedx.core.djangoapps.content_libraries import api as lib_api
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
Expand Down Expand Up @@ -1069,11 +1073,18 @@ def _get_user_orgs(request: Request) -> list[str]:
Get the org.short_names for the organizations that the requesting user has OrgStaffRole or OrgInstructorRole.

Note: org-level roles have course_id=None to distinguish them from course-level roles.

Also includes orgs where the user holds an org-wide (glob) authz course role, so that
authz-only users granted at the org level (e.g. ``course-v1:Org+*``) are covered by the
``org IN [...]`` search filter clause rather than being dropped.
"""
course_roles = get_course_roles(request.user)
return list(
set(role.org for role in course_roles if role.course_id is None and role.role in ["staff", "instructor"])
orgs = set(
role.org for role in course_roles if role.course_id is None and role.role in ["staff", "instructor"]
)
# Union in org-level authz grants (flag-gated per org inside the helper).
orgs.update(get_authz_org_keys(request.user.username, omit_orgs=list(orgs)))
return list(orgs)


def _get_meili_access_filter(request: Request) -> dict:
Expand Down
154 changes: 151 additions & 3 deletions openedx/core/djangoapps/content/search/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@

from __future__ import annotations

from django.db import models
import logging

from django.db import DatabaseError, models
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.django.models import LearningContextKeyField
from opaque_keys.edx.keys import CourseKey
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData
from openedx_authz.api.users import get_user_role_assignments
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 import toggles as core_toggles
from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user
from openedx.core.lib.cache_utils import request_cached

log = logging.getLogger(__name__)


class SearchAccess(models.Model): # noqa: DJ008
Expand Down Expand Up @@ -46,14 +56,20 @@ def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) ->
omit_orgs = omit_orgs or []

course_roles = get_course_roles(request.user)
course_clause = models.Q(context_key__in=[
course_keys = set(
role.course_id
for role in course_roles
if (
role.role in [CourseInstructorRole.ROLE, CourseStaffRole.ROLE]
and role.org not in omit_orgs
)
])
)

# When authz is enabled, also include courses where the user has an authz role assignment.
# This ensures authz-only users (editor/auditor without legacy roles) can search their courses.
course_keys.update(_get_authz_course_keys(request.user.username, omit_orgs))

course_clause = models.Q(context_key__in=list(course_keys))

libraries = get_libraries_for_user(user=request.user)
library_clause = models.Q(context_key__in=[
Expand All @@ -69,6 +85,138 @@ def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) ->
)


@request_cached()
def _get_cached_authz_assignments(username: str):
"""
Returns the user's full authz role-assignment set, cached per request.

``get_user_role_assignments_per_scope_type`` fetches the user's entire
assignment set from the enforcer regardless of the scope types requested,
then filters in Python. Both search helpers below need a different slice of
that same set (per-course vs org-glob scopes), so caching the single
underlying whole-set fetch lets them each filter by scope type without
paying for a second enforcer round-trip within one request.

Keyed on ``username`` only (the sole argument), so the two helpers share the
cache entry. Not wrapped in a try/except here: callers own the fail-open
decision, and ``request_cached`` never caches a raised exception, so a
transient ``DatabaseError`` on the first call is re-attempted on the second.
"""
return get_user_role_assignments(user_external_key=username)


def _get_authz_course_keys(username: str, omit_orgs: list[str]) -> set[str]:
"""
Returns serialized course keys from the user's authz role assignments where
the authz course authoring flag is enabled.

Filters the per-request-cached whole assignment set to the per-course
(``CourseOverviewData``) scopes, then to only courses where the flag is
active (supporting both global enablement and per-course overrides).

Keys are returned as strings (not ``CourseKey`` objects) to match the legacy
``get_course_roles`` branch, whose ``course_id`` is already a string. This
keeps the unioned ``course_keys`` set type-homogeneous so a course held via
both a legacy and an authz role de-duplicates to a single entry.

Fails open: if the authz lookup hits a database error, it is logged and an
empty set is returned so that search degrades to legacy-role access rather
than returning a 500. Any other (unexpected) exception propagates.
"""
try:
assignments = _get_cached_authz_assignments(username)
except DatabaseError as exc:
log.warning(
"Could not load authz role assignments for user %r; "
"falling back to legacy course roles for search access. Error: %s",
username,
exc,
)
return set()

course_keys = set()
for assignment in assignments:
if not isinstance(assignment.scope, CourseOverviewData):
continue
try:
course_key = CourseKey.from_string(assignment.scope.external_key)
except InvalidKeyError:
# A non-course scope (e.g. a library) can legitimately appear here; skip it.
continue
if course_key.org not in omit_orgs and core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
# Store the serialized form to match the legacy branch's string
# course_id, so the unioned set de-duplicates across both paths.
course_keys.add(str(course_key))
return course_keys


def _authz_flag_enabled_for_org(org: str) -> bool:
"""
Returns whether the authz course authoring flag is enabled for an entire org.

``AUTHZ_COURSE_AUTHORING_FLAG`` is a ``CourseWaffleFlag`` whose public
``is_enabled`` only accepts a course key; there is no public org-only check.
An org-wide (glob) grant is not tied to a single course, so we resolve the
flag the same way ``CourseWaffleFlag`` does internally for the org tier:
an org override (force-on / force-off) takes precedence, otherwise fall back
to the global waffle switch. A per-course override is intentionally not
consulted -- it cannot gate an org-wide grant.
"""
# Imported here rather than at module load to avoid pulling waffle models
# into this module's import graph before the app registry is ready.
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagOrgOverrideModel

flag = core_toggles.AUTHZ_COURSE_AUTHORING_FLAG
org_override = WaffleFlagOrgOverrideModel.override_value(flag.name, org)
if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.on:
return True
if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.off:
return False
return flag.is_enabled()


def get_authz_org_keys(username: str, omit_orgs: list[str]) -> set[str]:
"""
Returns org short_names from the user's org-level (glob) authz course role
assignments where the authz course authoring flag is enabled for that org.

An authz role can be granted at an org-wide scope (e.g. ``course-v1:Org+*``),
which surfaces as an ``OrgCourseOverviewGlobData`` scope rather than a
per-course ``CourseOverviewData`` scope. Such a grant means "all courses in
this org", so it belongs in the Meilisearch ``org IN [...]`` clause -- one
entry per org rather than fanning out to every course id (which would burn
``MAX_ACCESS_IDS_IN_FILTER`` slots) and mirrors how legacy org staff roles
are handled.

Filters the per-request-cached whole assignment set to the org-glob scopes,
so this and ``_get_authz_course_keys`` share a single enforcer fetch within
one request.

Fails open on a database error (logged, returns an empty set) so search
degrades to legacy access rather than 500-ing; any other exception
propagates. Orgs already covered by ``omit_orgs`` are skipped.
"""
try:
assignments = _get_cached_authz_assignments(username)
except DatabaseError as exc:
log.warning(
"Could not load authz org role assignments for user %r; "
"falling back to legacy roles for search access. Error: %s",
username,
exc,
)
return set()

org_keys = set()
for assignment in assignments:
if not isinstance(assignment.scope, OrgCourseOverviewGlobData):
continue
org = assignment.scope.org
if org and org not in omit_orgs and _authz_flag_enabled_for_org(org):
org_keys.add(org)
Comment on lines +210 to +216
return org_keys


class IncrementalIndexCompleted(models.Model): # noqa: DJ008
"""
Stores the contex keys of aleady indexed courses and libraries for incremental indexing.
Expand Down
Loading
Loading