Skip to content
Merged
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ Change Log
Unreleased
**********

1.22.0 - 2026-08-12
*******************

Added
=====

* Role assignment now succeeds for a course/library scope key before its CourseOverview/ContentLibrary
exists, and links up automatically once the object is created. (#369)

1.21.3 - 2026-08-04
*******************

Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "1.21.3"
__version__ = "1.22.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
48 changes: 48 additions & 0 deletions openedx_authz/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from casbin_adapter.models import CasbinRule
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from openedx_events.authz.signals import ROLE_ASSIGNMENT_CREATED, ROLE_ASSIGNMENT_DELETED
Expand All @@ -20,6 +21,7 @@
from openedx_authz.engine.utils import run_course_authoring_migration
from openedx_authz.models.authz_migration import MigrationType, ScopeType
from openedx_authz.models.core import ExtendedCasbinRule, RoleAssignmentAudit
from openedx_authz.models.scopes import ContentLibrary, ContentLibraryScope, CourseOverview, CourseScope
from openedx_authz.models.subjects import UserSubject

try:
Expand Down Expand Up @@ -141,6 +143,52 @@ def handle_org_waffle_flag_change(sender, instance, **kwargs) -> None:
post_save.connect(handle_org_waffle_flag_change, sender=WaffleFlagOrgOverrideModel)


def backfill_course_scope(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Link a pending CourseScope to the CourseOverview that was just created for it.

get_or_create_for_external_key() (openedx_authz/models/scopes.py) allows assigning a
role for a course key before its CourseOverview exists, leaving course_overview null.
This backfills that FK once the course shows up, e.g. after a course rerun finishes
cloning. See CourseScope.link_pending_scope() for the actual lookup/update.

This runs on every CourseOverview save, so errors are logged rather than raised:
a problem backfilling a pending scope must not break the caller's save(). The query
runs in its own savepoint so a failure doesn't poison the caller's transaction.
"""
try:
with transaction.atomic():
CourseScope.link_pending_scope(instance)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.exception(
"Error linking pending CourseScope to CourseOverview %s", instance.pk, exc_info=exc,
)


def backfill_content_library_scope(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Link a pending ContentLibraryScope to the ContentLibrary that was just created for it.

See backfill_course_scope() above; same idea for content libraries, including the
broad exception handling and the isolating savepoint.
"""
try:
with transaction.atomic():
ContentLibraryScope.link_pending_scope(instance)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.exception(
"Error linking pending ContentLibraryScope to ContentLibrary %s", instance.pk, exc_info=exc,
)


# Only register the handlers if the models are available (i.e., running in Open edX)
if CourseOverview is not None:
post_save.connect(backfill_course_scope, sender=CourseOverview)

if ContentLibrary is not None:
post_save.connect(backfill_content_library_scope, sender=ContentLibrary)


# Match ``WaffleFlagCourseOverrideModel.OVERRIDE_CHOICES`` / ``override_value`` in edx-platform:
# the flag is effectively forced on only when the row is enabled and ``override_choice`` is "on".
WAFFLE_OVERRIDE_FORCE_ON = "on"
Expand Down
18 changes: 18 additions & 0 deletions openedx_authz/migrations/0010_scope_external_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-07-29 19:03

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('openedx_authz', '0009_roleassignmentaudit'),
]

operations = [
migrations.AddField(
model_name='scope',
name='external_key',
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
]
84 changes: 82 additions & 2 deletions openedx_authz/models/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,95 @@ class Scope(BaseRegistryModel):
This model can be extended to represent different types of scopes,
such as courses or content libraries.

Subclasses should define a NAMESPACE class attribute (e.g., 'lib' for content libraries)
and implement get_or_create_for_external_key() classmethod.
Subclasses should define a NAMESPACE class attribute (e.g., 'lib' for content libraries),
a LINKED_OBJECT_FIELD naming their FK to the backing object (e.g. 'content_library'), and
implement external_key_for_object().
"""

objects = ScopeManager()

# Name of the subclass's FK field pointing at its backing object (e.g. "content_library",
# "course_overview"), used by get_or_create_for_external_key()/link_pending_scope() below
# to work generically across scope types.
LINKED_OBJECT_FIELD: ClassVar[str] = None

# Canonical string form of the scope's key (e.g. a course-v1 course id), set on creation
# regardless of whether the backing object (CourseOverview, ContentLibrary, ...) exists yet.
# This is the only way to find a scope back again when its FK to that object is still null
# so it can be linked up once the object is created (see link_pending_scope() below and the
# backfill signal receivers in openedx_authz/handlers.py).
external_key = models.CharField(max_length=255, null=True, blank=True, unique=True)

class Meta:
abstract = False

@classmethod
def external_key_for_object(cls, linked_object) -> str:
"""Return the canonical external_key string for one of this scope's backing objects.

Subclasses must override this to match how their ScopeData computes external_key
(e.g. the course id, or the library key).

Args:
linked_object: An instance of the model named by LINKED_OBJECT_FIELD.

Returns:
str: The canonical external_key for that instance.
"""
raise NotImplementedError

@classmethod
def get_or_create_for_external_key(cls, scope) -> "Scope":
"""Get or create a scope instance for the given external key.

The backing object (CourseOverview, ContentLibrary, ...) need not exist yet (e.g.
during a course rerun, the destination course id is known before the course is
cloned): the scope is created with its LINKED_OBJECT_FIELD left ``None`` in that
case, and gets linked up automatically once a matching object is saved (see
link_pending_scope() below and the backfill signal receivers in
openedx_authz/handlers.py).

Args:
scope: ScopeData object with an external_key attribute.

Returns:
Scope: The (possibly newly created) scope instance for this subclass.
"""
external_key = scope.external_key
linked_object = scope.get_object()
if linked_object is None:
# The object doesn't exist yet: key the row by its external_key so it can be
# found and linked up later by link_pending_scope().
scope, _ = cls.objects.get_or_create(external_key=external_key)
return scope

# Look up by the FK first (as before the external_key field existed) so scopes
# created before this change are reused rather than duplicated.
scope, created = cls.objects.get_or_create(
**{cls.LINKED_OBJECT_FIELD: linked_object},
defaults={"external_key": external_key},
)
if not created and not scope.external_key:
scope.external_key = external_key
scope.save(update_fields=["external_key"])
return scope

@classmethod
def link_pending_scope(cls, linked_object) -> None:
"""Link a pending scope to the object that was just created for it.

Called from the relevant post_save signal receiver in openedx_authz/handlers.py
once an object with a matching external_key shows up.

Args:
linked_object: The model instance (CourseOverview, ContentLibrary, ...) that
was just saved.
"""
cls.objects.filter(
external_key=cls.external_key_for_object(linked_object),
**{f"{cls.LINKED_OBJECT_FIELD}__isnull": True},
).update(**{cls.LINKED_OBJECT_FIELD: linked_object})


class Subject(BaseRegistryModel):
"""Model representing a subject in the authorization system.
Expand Down
40 changes: 8 additions & 32 deletions openedx_authz/models/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
from django.apps import apps
from django.conf import settings
from django.db import models
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocatorV2

from openedx_authz.models.core import Scope

Expand Down Expand Up @@ -61,6 +59,7 @@ class ContentLibraryScope(Scope):
"""

NAMESPACE = "lib"
LINKED_OBJECT_FIELD = "content_library"

# Link to the actual content library, if applicable. In other cases, this could be null.
# Piggybacking on the existing ContentLibrary model to keep the ExtendedCasbinRule up to date
Expand All @@ -81,21 +80,9 @@ class ContentLibraryScope(Scope):
)

@classmethod
def get_or_create_for_external_key(cls, scope) -> "ContentLibraryScope":
"""Get or create a ContentLibraryScope for the given external key.

Args:
scope: ScopeData object with an external_key attribute containing
a LibraryLocatorV2-compatible string.

Returns:
ContentLibraryScope: The Scope instance for the given ContentLibrary,
or None if the scope is a glob pattern (contains wildcard).
"""
library_key = LibraryLocatorV2.from_string(scope.external_key)
content_library = ContentLibrary.objects.get_by_key(library_key)
scope, _ = cls.objects.get_or_create(content_library=content_library)
return scope
def external_key_for_object(cls, linked_object) -> str:
"""Return the canonical external_key string for a ContentLibrary instance."""
return str(linked_object.library_key)


class CourseScope(Scope):
Expand All @@ -105,6 +92,7 @@ class CourseScope(Scope):
"""

NAMESPACE = "course-v1"
LINKED_OBJECT_FIELD = "course_overview"

# Link to the actual course, if applicable. In other cases, this could be null.
# Piggybacking on the existing CourseOverview model to keep the ExtendedCasbinRule up to date
Expand All @@ -125,18 +113,6 @@ class CourseScope(Scope):
)

@classmethod
def get_or_create_for_external_key(cls, scope) -> "CourseScope":
"""Get or create a CourseScope for the given external key.

Args:
scope: ScopeData object with an external_key attribute containing
a CourseKey string.

Returns:
CourseScope: The Scope instance for the given CourseOverview,
or None if the scope is a glob pattern (contains wildcard).
"""
course_key = CourseKey.from_string(scope.external_key)
course_overview = CourseOverview.get_from_id(course_key)
scope, _ = cls.objects.get_or_create(course_overview=course_overview)
return scope
def external_key_for_object(cls, linked_object) -> str:
"""Return the canonical external_key string for a CourseOverview instance."""
return str(linked_object.id)
Loading