Skip to content
Open
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
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-07-30
*******************

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.2 - 2026-07-29
*******************

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.2"
__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