Skip to content

Commit bc639d2

Browse files
feat: migrate org scope access forward and rollback from/to authz policies (#249)
1 parent 218e294 commit bc639d2

6 files changed

Lines changed: 333 additions & 68 deletions

File tree

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.6.0 - 2026-04-10
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add org-wide support to migration commands for forward and backward migration of course authoring permissions.
24+
1725
1.5.0 - 2026-04-09
1826
******************
1927

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.5.0"
7+
__version__ = "1.6.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,24 @@ def get_admin_view_permission(cls) -> PermissionData:
695695
"""
696696
raise NotImplementedError("Subclasses must implement get_admin_view_permission method.")
697697

698+
@classmethod
699+
def build_external_key(cls, org: str) -> str:
700+
"""Build the external key for all resources within the given organization.
701+
702+
Args:
703+
org (str): The organization identifier (e.g., ``DemoX``).
704+
705+
Returns:
706+
str: The external key for the org-level glob (e.g., ``course-v1:DemoX+*``).
707+
708+
Examples:
709+
>>> OrgCourseOverviewGlobData.build_external_key('DemoX')
710+
'course-v1:DemoX+*'
711+
>>> OrgContentLibraryGlobData.build_external_key('DemoX')
712+
'lib:DemoX:*'
713+
"""
714+
return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{org}{cls.ID_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}"
715+
698716
@classmethod
699717
def get_org(cls, external_key: str) -> str | None:
700718
"""Extract the organization identifier from the glob pattern.

openedx_authz/api/roles.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"get_subject_role_assignments",
4545
"get_subject_role_assignments_for_role_in_scope",
4646
"get_subject_role_assignments_in_scope",
47+
"get_all_role_assignments_per_scope_type",
4748
"unassign_role_from_subject_in_scope",
4849
"unassign_subject_from_all_roles",
4950
]
@@ -553,3 +554,23 @@ def unassign_subject_from_all_roles(subject: SubjectData) -> bool:
553554
"""
554555
enforcer = AuthzEnforcer.get_enforcer()
555556
return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key)
557+
558+
559+
def get_all_role_assignments_per_scope_type(scope_types: tuple[type[ScopeData], ...]) -> list[RoleAssignmentData]:
560+
"""Get all role assignments matching any of the given scope types.
561+
562+
Loads all grouping policies from the enforcer and filters in Python. Casbin policies
563+
store full scope keys (e.g., 'course-v1^course-v1:Org+Course+Run'), so there is no
564+
way to query by scope type directly so the filtering must happen here.
565+
566+
Args:
567+
scope_types: A list of ScopeData subclasses (not instances). Assignments matching
568+
any of the given types are returned.
569+
570+
Returns:
571+
list[RoleAssignmentData]: All assignments whose scope is an instance of any of the given scope types.
572+
"""
573+
return [
574+
role_assignment for role_assignment in get_role_assignments()
575+
if isinstance(role_assignment.scope, scope_types)
576+
]

openedx_authz/engine/utils.py

Lines changed: 101 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
from collections import defaultdict
99

1010
from casbin import Enforcer
11+
from django.db.models import Q
12+
from opaque_keys.edx.django.models import CourseKeyField
1113

12-
from openedx_authz.api.data import CourseOverviewData
14+
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData
15+
from openedx_authz.api.roles import get_all_role_assignments_per_scope_type
1316
from openedx_authz.api.users import (
1417
assign_role_to_user_in_scope,
1518
batch_assign_role_to_users_in_scope,
1619
batch_unassign_role_from_users,
17-
get_user_role_assignments,
1820
)
1921
from openedx_authz.constants.roles import (
2022
LEGACY_COURSE_ROLE_EQUIVALENCES,
@@ -204,6 +206,11 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
204206
- user: subject
205207
- role: role
206208
209+
The scope assigned per row depends on which fields are set:
210+
- course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024").
211+
- course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*").
212+
- both set: course_id takes precedence as the more specific scope.
213+
207214
param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
208215
is intended to run within a Django migration context, where direct model imports can cause issues.
209216
param course_id_list: Optional list of course IDs to filter the migration.
@@ -212,9 +219,7 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
212219
"""
213220
_validate_migration_input(course_id_list, org_id)
214221

215-
course_access_role_filter = {
216-
"course_id__startswith": "course-v1:",
217-
}
222+
course_access_role_filter = {}
218223

219224
if org_id:
220225
course_access_role_filter["org"] = org_id
@@ -225,7 +230,9 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
225230
course_access_role_filter["course_id__in"] = course_id_list
226231

227232
legacy_permissions = (
228-
course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all()
233+
course_access_role_model.objects.filter(**course_access_role_filter)
234+
.filter(Q(course_id=CourseKeyField.Empty) | Q(course_id__startswith=CourseOverviewData.NAMESPACE))
235+
.select_related("user")
229236
)
230237

231238
# List to keep track of any permissions that could not be migrated
@@ -243,16 +250,28 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
243250
permissions_with_errors.append(permission)
244251
continue
245252

253+
if permission.course_id:
254+
scope_external_key = str(permission.course_id)
255+
elif permission.org:
256+
scope_external_key = OrgCourseOverviewGlobData.build_external_key(permission.org)
257+
else:
258+
# Instance-wide roles (no course_id, no org) are not supported by this migration, log and skip.
259+
logger.error(
260+
f"Permission for User: {permission.user.username} has neither course_id nor org defined, skipping."
261+
)
262+
permissions_with_errors.append(permission)
263+
continue
264+
246265
# Permission applied to individual user
247266
logger.info(
248267
f"Migrating permission for User: {permission.user.username} "
249-
f"to Role: {role} in Scope: {permission.course_id}"
268+
f"to Role: {role} in Scope: {scope_external_key}"
250269
)
251270

252271
is_user_added = assign_role_to_user_in_scope(
253272
user_external_key=permission.user.username,
254273
role_external_key=role,
255-
scope_external_key=str(permission.course_id),
274+
scope_external_key=scope_external_key,
256275
)
257276

258277
if not is_user_added:
@@ -286,6 +305,11 @@ def migrate_authz_to_legacy_course_roles(
286305
This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
287306
for rollback purposes in case of migration issues.
288307
308+
To build each CourseAccessRole entry, the function needs:
309+
- A user: resolved from role assignments in scopes linked to courses.
310+
- A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id.
311+
- A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES.
312+
289313
param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
290314
is intended to run within a Django migration context, where direct model imports can cause issues.
291315
param user_subject_model: It should be the UserSubject model. This is passed in because the function
@@ -297,70 +321,87 @@ def migrate_authz_to_legacy_course_roles(
297321
"""
298322
_validate_migration_input(course_id_list, org_id)
299323

300-
# 1. Get all users with course-related permissions in the new model by filtering
301-
# UserSubjects that are linked to CourseScopes with a valid course overview.
302-
course_subject_filter = {
303-
"casbin_rules__scope__coursescope__course_overview__isnull": False,
304-
}
324+
role_assignments = get_all_role_assignments_per_scope_type(
325+
scope_types=(CourseOverviewData, OrgCourseOverviewGlobData,)
326+
)
305327

328+
# Two cases here:
329+
# 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org.
330+
# 2. only course_id_list provided: filter by course_id — org-level glob scopes are excluded (no course_id).
306331
if org_id:
307-
course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id
332+
role_assignments = [
333+
role_assignment
334+
for role_assignment in role_assignments
335+
if role_assignment.scope.org == org_id
336+
]
308337

309338
if course_id_list and not org_id:
310-
# Only filter by course_id if org_id is not provided,
311-
# otherwise we will filter by org_id which is more efficient
312-
course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list
313-
314-
course_subjects = user_subject_model.objects.filter(**course_subject_filter).select_related("user").distinct()
339+
role_assignments = [
340+
role_assignment
341+
for role_assignment in role_assignments
342+
if isinstance(role_assignment.scope, CourseOverviewData) and
343+
role_assignment.scope.course_id in course_id_list
344+
]
315345

316346
roles_with_errors = []
317347
roles_with_no_errors = []
318348
unassignments = defaultdict(list)
319349

320-
for course_subject in course_subjects:
321-
user = course_subject.user
322-
user_external_key = user.username
323-
324-
# 2. Get all role assignments for the user
325-
role_assignments = get_user_role_assignments(user_external_key=user_external_key)
350+
user_external_keys = {assignment.subject.external_key for assignment in role_assignments}
351+
users_by_username = {
352+
subject.user.username: subject.user
353+
for subject in user_subject_model.objects.filter(
354+
user__username__in=user_external_keys
355+
).select_related("user")
356+
}
326357

327-
for assignment in role_assignments:
328-
if not isinstance(assignment.scope, CourseOverviewData):
329-
logger.error(f"Skipping role assignment for User: {user_external_key} due to missing course scope.")
358+
for role_assignment in role_assignments:
359+
360+
# Per valid role assignment, create corresponding CourseAccessRole entry
361+
# depending on whether the scope is course-level or org-level glob
362+
try:
363+
user_external_key = role_assignment.subject.external_key
364+
role_external_key = role_assignment.roles[0].external_key
365+
scope_external_key = role_assignment.scope.external_key
366+
367+
course_access_role_kwargs = {
368+
"user": users_by_username[user_external_key],
369+
"role": COURSE_ROLE_EQUIVALENCES[role_external_key],
370+
}
371+
372+
if isinstance(role_assignment.scope, CourseOverviewData):
373+
course_access_role_kwargs["org"] = role_assignment.scope.org
374+
course_access_role_kwargs["course_id"] = scope_external_key
375+
elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData):
376+
course_access_role_kwargs["org"] = role_assignment.scope.org
377+
else:
378+
# This would only happen for course roles assigned instance-wide
379+
# which is not yet supported
380+
logger.error(
381+
f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with "
382+
f"scope: {scope_external_key}, user: {user_external_key} and role: {role_external_key}, skipping."
383+
)
384+
roles_with_errors.append(role_assignment)
330385
continue
331386

332-
scope = assignment.scope.external_key
333-
334-
course_overview = assignment.scope.get_object()
335-
336-
for role in assignment.roles:
337-
legacy_role = COURSE_ROLE_EQUIVALENCES.get(role.external_key)
338-
if legacy_role is None:
339-
logger.error(f"Unknown role: {role} for User: {user_external_key}")
340-
roles_with_errors.append((user_external_key, role.external_key, scope))
341-
continue
342-
343-
try:
344-
# Create legacy CourseAccessRole entry
345-
course_access_role_model.objects.get_or_create(
346-
user=user,
347-
org=course_overview.org,
348-
course_id=scope,
349-
role=legacy_role,
350-
)
351-
roles_with_no_errors.append((user_external_key, role.external_key, scope))
352-
except Exception as e: # pylint: disable=broad-exception-caught
353-
logger.error(
354-
f"Error creating CourseAccessRole for User: "
355-
f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}"
356-
)
357-
roles_with_errors.append((user_external_key, role.external_key, scope))
358-
continue
359-
360-
# If we successfully created the legacy role, we can add this role assignment
361-
# to the unassignment list if delete_after_migration is True
362-
if delete_after_migration:
363-
unassignments[(role.external_key, scope)].append(user_external_key)
387+
course_access_role_model.objects.get_or_create(**course_access_role_kwargs)
388+
roles_with_no_errors.append(role_assignment)
389+
390+
logger.info(
391+
f"Successfully rolled back RoleAssignment for User: {user_external_key} "
392+
f"in Role: {role_external_key} and Scope: {scope_external_key} "
393+
f"to legacy CourseAccessRole entry."
394+
)
395+
396+
if delete_after_migration:
397+
unassignments[(role_external_key, scope_external_key)].append(user_external_key)
398+
399+
except Exception as e: # pylint: disable=broad-exception-caught
400+
logger.error(
401+
f"Error rolling back RoleAssignment for User: {role_assignment.subject.external_key} "
402+
f"in Role: {role_assignment.roles[0].external_key} and Scope: {role_assignment.scope.external_key}: {e}"
403+
)
404+
roles_with_errors.append(role_assignment)
364405

365406
# Once the loop is done, we can log summary of unassignments
366407
# and perform batch unassignment if delete_after_migration is True

0 commit comments

Comments
 (0)