Skip to content

Commit f4f09ee

Browse files
authored
feat: add course authoring migration and rollback scripts (#218)
* feat: add course authoring migration and rollback scripts - Add `authz_migrate_course_authoring` command to migrate legacy CourseAccessRole data to the new Authz (Casbin-based) system - Add `authz_rollback_course_authoring` command to rollback Authz roles back to legacy CourseAccessRole - Support optional `--delete` flag for controlled cleanup of source permissions after successful migration - Add `migrate_legacy_course_roles_to_authz` and `migrate_authz_to_legacy_course_roles` service functions - Add unit tests to verify migration and command behavior * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts * fixup! feat: add course authoring migration and rollback scripts
1 parent d4791c8 commit f4f09ee

File tree

7 files changed

+1450
-8
lines changed

7 files changed

+1450
-8
lines changed

CHANGELOG.rst

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

17+
0.23.0 - 2026-02-18
18+
********************
19+
20+
Added
21+
=====
22+
23+
* Add authz_migrate_course_authoring command to migrate legacy CourseAccessRole data to the new Authz (Casbin-based) system
24+
* Add authz_rollback_course_authoring command to rollback Authz roles back to legacy CourseAccessRole
25+
* Support optional --delete flag for controlled cleanup of source permissions after successful migration
26+
* Add migrate_legacy_course_roles_to_authz and migrate_authz_to_legacy_course_roles service functions
27+
* Add unit tests to verify migration and command behavior
28+
1729
Added
1830
=====
1931

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__ = "0.22.0"
7+
__version__ = "0.23.0"
88

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

openedx_authz/engine/utils.py

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,33 @@
55
"""
66

77
import logging
8+
from collections import defaultdict
89

910
from casbin import Enforcer
1011

11-
from openedx_authz.api.users import assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope
12-
from openedx_authz.constants.roles import LIBRARY_ADMIN, LIBRARY_AUTHOR, LIBRARY_USER
12+
from openedx_authz.api.data import CourseOverviewData
13+
from openedx_authz.api.users import (
14+
assign_role_to_user_in_scope,
15+
batch_assign_role_to_users_in_scope,
16+
batch_unassign_role_from_users,
17+
get_user_role_assignments,
18+
)
19+
from openedx_authz.constants.roles import (
20+
LEGACY_COURSE_ROLE_EQUIVALENCES,
21+
LIBRARY_ADMIN,
22+
LIBRARY_AUTHOR,
23+
LIBRARY_USER,
24+
)
1325

1426
logger = logging.getLogger(__name__)
1527

1628
GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"]
1729

1830

31+
# Map new roles back to legacy roles for rollback purposes
32+
COURSE_ROLE_EQUIVALENCES = {v: k for k, v in LEGACY_COURSE_ROLE_EQUIVALENCES.items()}
33+
34+
1935
def migrate_policy_between_enforcers(
2036
source_enforcer: Enforcer,
2137
target_enforcer: Enforcer,
@@ -151,3 +167,203 @@ def migrate_legacy_permissions(ContentLibraryPermission):
151167
)
152168

153169
return permissions_with_errors
170+
171+
172+
def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration):
173+
"""
174+
Migrate legacy course role data to the new Casbin-based authorization model.
175+
This function reads legacy permissions from the CourseAccessRole model
176+
and assigns equivalent roles in the new authorization system.
177+
178+
The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns:
179+
180+
- user: FK to User
181+
- org: optional Organization string
182+
- course_id: optional CourseKeyField of Course
183+
- role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher'
184+
185+
In the new Authz model, this would roughly translate to:
186+
187+
- course_id: scope
188+
- user: subject
189+
- role: role
190+
191+
param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
192+
is intended to run within a Django migration context, where direct model imports can cause issues.
193+
param course_id_list: Optional list of course IDs to filter the migration.
194+
param org_id: Optional organization ID to filter the migration.
195+
param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration.
196+
"""
197+
if not course_id_list and not org_id:
198+
raise ValueError(
199+
"At least one of course_id_list or org_id must be provided to limit the scope of the migration."
200+
)
201+
course_access_role_filter = {
202+
"course_id__startswith": "course-v1:",
203+
}
204+
205+
if org_id:
206+
course_access_role_filter["org"] = org_id
207+
208+
if course_id_list and not org_id:
209+
# Only filter by course_id if org_id is not provided,
210+
# otherwise we will filter by org_id which is more efficient
211+
course_access_role_filter["course_id__in"] = course_id_list
212+
213+
legacy_permissions = (
214+
course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all()
215+
)
216+
217+
# List to keep track of any permissions that could not be migrated
218+
permissions_with_errors = []
219+
permissions_with_no_errors = []
220+
221+
for permission in legacy_permissions:
222+
# Migrate the permission to the new model
223+
224+
role = LEGACY_COURSE_ROLE_EQUIVALENCES.get(permission.role)
225+
if role is None:
226+
# This should not happen as there are no more access_levels defined
227+
# in CourseAccessRole, log and skip
228+
logger.error(f"Unknown access level: {permission.role} for User: {permission.user}")
229+
permissions_with_errors.append(permission)
230+
continue
231+
232+
# Permission applied to individual user
233+
logger.info(
234+
f"Migrating permission for User: {permission.user.username} "
235+
f"to Role: {role} in Scope: {permission.course_id}"
236+
)
237+
238+
is_user_added = assign_role_to_user_in_scope(
239+
user_external_key=permission.user.username,
240+
role_external_key=role,
241+
scope_external_key=str(permission.course_id),
242+
)
243+
244+
if not is_user_added:
245+
logger.error(
246+
f"Failed to migrate permission for User: {permission.user.username} "
247+
f"to Role: {role} in Scope: {permission.course_id}"
248+
)
249+
permissions_with_errors.append(permission)
250+
continue
251+
252+
permissions_with_no_errors.append(permission)
253+
254+
if delete_after_migration:
255+
# Only delete permissions that were successfully migrated to avoid data loss.
256+
course_access_role_model.objects.filter(id__in=[p.id for p in permissions_with_no_errors]).delete()
257+
logger.info(f"Deleted {len(permissions_with_no_errors)} legacy permissions after successful migration.")
258+
logger.info(f"Retained {len(permissions_with_errors)} legacy permissions that had errors during migration.")
259+
260+
return permissions_with_errors, permissions_with_no_errors
261+
262+
263+
def migrate_authz_to_legacy_course_roles(
264+
course_access_role_model, user_subject_model, course_id_list, org_id, delete_after_migration
265+
):
266+
"""
267+
Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model.
268+
This function reads permissions from the Casbin enforcer and creates equivalent entries in the
269+
CourseAccessRole model.
270+
271+
This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
272+
for rollback purposes in case of migration issues.
273+
274+
param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
275+
is intended to run within a Django migration context, where direct model imports can cause issues.
276+
param user_subject_model: It should be the UserSubject model. This is passed in because the function
277+
is intended to run within a Django migration context, where direct model imports can cause issues.
278+
param course_id_list: Optional list of course IDs to filter the migration.
279+
param org_id: Optional organization ID to filter the migration.
280+
param delete_after_migration: Whether to unassign successfully migrated permissions
281+
from the new model after migration.
282+
"""
283+
if not course_id_list and not org_id:
284+
raise ValueError(
285+
"At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration."
286+
)
287+
288+
# 1. Get all users with course-related permissions in the new model by filtering
289+
# UserSubjects that are linked to CourseScopes with a valid course overview.
290+
course_subject_filter = {
291+
"casbin_rules__scope__coursescope__course_overview__isnull": False,
292+
}
293+
294+
if org_id:
295+
course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id
296+
297+
if course_id_list and not org_id:
298+
# Only filter by course_id if org_id is not provided,
299+
# otherwise we will filter by org_id which is more efficient
300+
course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list
301+
302+
course_subjects = user_subject_model.objects.filter(**course_subject_filter).select_related("user").distinct()
303+
304+
roles_with_errors = []
305+
roles_with_no_errors = []
306+
unassignments = defaultdict(list)
307+
308+
for course_subject in course_subjects:
309+
user = course_subject.user
310+
user_external_key = user.username
311+
312+
# 2. Get all role assignments for the user
313+
role_assignments = get_user_role_assignments(user_external_key=user_external_key)
314+
315+
for assignment in role_assignments:
316+
if not isinstance(assignment.scope, CourseOverviewData):
317+
logger.error(f"Skipping role assignment for User: {user_external_key} due to missing course scope.")
318+
continue
319+
320+
scope = assignment.scope.external_key
321+
322+
course_overview = assignment.scope.get_object()
323+
324+
for role in assignment.roles:
325+
legacy_role = COURSE_ROLE_EQUIVALENCES.get(role.external_key)
326+
if legacy_role is None:
327+
logger.error(f"Unknown role: {role} for User: {user_external_key}")
328+
roles_with_errors.append((user_external_key, role.external_key, scope))
329+
continue
330+
331+
try:
332+
# Create legacy CourseAccessRole entry
333+
course_access_role_model.objects.get_or_create(
334+
user=user,
335+
org=course_overview.org,
336+
course_id=scope,
337+
role=legacy_role,
338+
)
339+
roles_with_no_errors.append((user_external_key, role.external_key, scope))
340+
except Exception as e: # pylint: disable=broad-exception-caught
341+
logger.error(
342+
f"Error creating CourseAccessRole for User: "
343+
f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}"
344+
)
345+
roles_with_errors.append((user_external_key, role.external_key, scope))
346+
continue
347+
348+
# If we successfully created the legacy role, we can add this role assignment
349+
# to the unassignment list if delete_after_migration is True
350+
if delete_after_migration:
351+
unassignments[(role.external_key, scope)].append(user_external_key)
352+
353+
# Once the loop is done, we can log summary of unassignments
354+
# and perform batch unassignment if delete_after_migration is True
355+
if delete_after_migration:
356+
total_unassignments = sum(len(users) for users in unassignments.values())
357+
logger.info(f"Total of {total_unassignments} role assignments unassigned after successful rollback migration.")
358+
for (role_external_key, scope), users in unassignments.items():
359+
logger.info(
360+
f"Unassigned Role: {role_external_key} from {len(users)} users \n"
361+
f"in Scope: {scope} after successful rollback migration."
362+
)
363+
batch_unassign_role_from_users(
364+
users=users,
365+
role_external_key=role_external_key,
366+
scope_external_key=scope,
367+
)
368+
369+
return roles_with_errors, roles_with_no_errors
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Django management command to migrate legacy course authoring roles to the new Authz (Casbin-based) authorization system.
3+
"""
4+
5+
from django.core.management.base import BaseCommand, CommandError
6+
from django.db import transaction
7+
8+
from openedx_authz.engine.utils import migrate_legacy_course_roles_to_authz
9+
10+
try:
11+
from common.djangoapps.student.models import CourseAccessRole
12+
except ImportError:
13+
CourseAccessRole = None # type: ignore
14+
15+
16+
class Command(BaseCommand):
17+
"""
18+
Django command to migrate legacy CourseAccessRole data
19+
to the new Authz (Casbin-based) authorization system.
20+
"""
21+
22+
help = "Migrate legacy course authoring roles to the new Authz system."
23+
24+
def add_arguments(self, parser):
25+
parser.add_argument(
26+
"--delete",
27+
action="store_true",
28+
help="Delete legacy CourseAccessRole records after successful migration.",
29+
)
30+
parser.add_argument(
31+
"--course-id-list",
32+
nargs="+",
33+
type=str,
34+
help="Optional list of course IDs to filter the migration.",
35+
)
36+
37+
parser.add_argument(
38+
"--org-id",
39+
type=str,
40+
help="Optional organization ID to filter the migration.",
41+
)
42+
43+
def handle(self, *args, **options):
44+
delete_after_migration = options["delete"]
45+
course_id_list = options.get("course_id_list")
46+
org_id = options.get("org_id")
47+
48+
if not course_id_list and not org_id:
49+
raise CommandError("You must specify either --course-id-list or --org-id to filter the migration.")
50+
51+
if course_id_list and org_id:
52+
raise CommandError("You cannot use --course-id-list and --org-id together.")
53+
54+
self.stdout.write(self.style.WARNING("Starting legacy → Authz migration..."))
55+
56+
try:
57+
if delete_after_migration:
58+
confirm = input(
59+
"Are you sure you want to delete successfully migrated legacy roles? Type 'yes' to continue: "
60+
)
61+
62+
if confirm != "yes":
63+
self.stdout.write(self.style.WARNING("Migration aborted."))
64+
return
65+
with transaction.atomic():
66+
errors, success = migrate_legacy_course_roles_to_authz(
67+
course_access_role_model=CourseAccessRole,
68+
course_id_list=course_id_list,
69+
org_id=org_id,
70+
delete_after_migration=delete_after_migration,
71+
)
72+
73+
if errors:
74+
self.stdout.write(self.style.ERROR(f"Migration completed with {len(errors)} errors."))
75+
else:
76+
self.stdout.write(
77+
self.style.SUCCESS(f"Migration completed successfully with {len(success)} roles migrated.")
78+
)
79+
80+
if delete_after_migration:
81+
self.stdout.write(self.style.SUCCESS(f"{len(success)} Legacy roles deleted successfully."))
82+
83+
except Exception as exc:
84+
self.stdout.write(self.style.ERROR(f"Migration failed due to unexpected error: {exc}"))
85+
raise
86+
87+
self.stdout.write(self.style.SUCCESS("Done."))

0 commit comments

Comments
 (0)