Skip to content

Commit d6bf4b1

Browse files
committed
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 - Add Django data migration to automatically trigger the migration
1 parent f284f1c commit d6bf4b1

7 files changed

Lines changed: 926 additions & 7 deletions

File tree

openedx_authz/constants/roles.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@
160160
permissions.COURSES_EXPORT_TAGS,
161161
]
162162

163+
COURSE_LIMITED_STAFF_PERMISSIONS = []
164+
165+
COURSE_DATA_RESEARCHER_PERMISSIONS = []
166+
167+
COURSE_ADMIN = RoleData(external_key="course_admin", permissions=COURSE_ADMIN_PERMISSIONS)
163168
COURSE_STAFF = RoleData(external_key="course_staff", permissions=COURSE_STAFF_PERMISSIONS)
164169

165170
COURSE_LIMITED_STAFF_PERMISSIONS = [

openedx_authz/engine/utils.py

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,21 @@
88

99
from casbin import Enforcer
1010

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
11+
from openedx_authz.api.users import (
12+
assign_role_to_user_in_scope,
13+
batch_assign_role_to_users_in_scope,
14+
batch_unassign_role_from_users,
15+
get_user_role_assignments,
16+
)
17+
from openedx_authz.constants.roles import (
18+
COURSE_ADMIN,
19+
COURSE_DATA_RESEARCHER,
20+
COURSE_LIMITED_STAFF,
21+
COURSE_STAFF,
22+
LIBRARY_ADMIN,
23+
LIBRARY_AUTHOR,
24+
LIBRARY_USER,
25+
)
1326

1427
logger = logging.getLogger(__name__)
1528

@@ -151,3 +164,145 @@ def migrate_legacy_permissions(ContentLibraryPermission):
151164
)
152165

153166
return permissions_with_errors
167+
168+
169+
def migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migration):
170+
"""
171+
Migrate legacy course role data to the new Casbin-based authorization model.
172+
This function reads legacy permissions from the CourseAccessRole model
173+
and assigns equivalent roles in the new authorization system.
174+
175+
The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns:
176+
177+
- user: FK to User
178+
- org: optional Organization string
179+
- course_id: optional CourseKeyField of Course
180+
- role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher'
181+
182+
In the new Authz model, this would roughly translate to:
183+
184+
- course_id: scope
185+
- user: subject
186+
- role: role
187+
188+
param CourseAccessRole: The CourseAccessRole model to use.
189+
"""
190+
191+
legacy_permissions = CourseAccessRole.objects.select_related("user").all()
192+
193+
# List to keep track of any permissions that could not be migrated
194+
permissions_with_errors = []
195+
permissions_with_no_errors = []
196+
197+
for permission in legacy_permissions:
198+
# Migrate the permission to the new model
199+
200+
# Derive equivalent role based on access level
201+
map_legacy_role = {
202+
"instructor": COURSE_ADMIN,
203+
"staff": COURSE_STAFF,
204+
"limited_staff": COURSE_LIMITED_STAFF,
205+
"data_researcher": COURSE_DATA_RESEARCHER,
206+
}
207+
208+
role = map_legacy_role.get(permission.role)
209+
if role is None:
210+
# This should not happen as there are no more access_levels defined
211+
# in CourseAccessRole, log and skip
212+
logger.error(f"Unknown access level: {permission.role} for User: {permission.user}")
213+
permissions_with_errors.append(permission)
214+
continue
215+
216+
# Permission applied to individual user
217+
logger.info(
218+
f"Migrating permission for User: {permission.user.username} "
219+
f"to Role: {role.external_key} in Scope: {permission.course_id}"
220+
)
221+
222+
assign_role_to_user_in_scope(
223+
user_external_key=permission.user.username,
224+
role_external_key=role.external_key,
225+
scope_external_key=str(permission.course_id),
226+
)
227+
permissions_with_no_errors.append(permission)
228+
229+
if delete_after_migration:
230+
CourseAccessRole.objects.filter(id__in=[p.id for p in permissions_with_no_errors]).delete()
231+
232+
return permissions_with_errors
233+
234+
235+
def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, delete_after_migration):
236+
"""
237+
Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model.
238+
This function reads permissions from the Casbin enforcer and creates equivalent entries in the
239+
CourseAccessRole model.
240+
241+
This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
242+
for rollback purposes in case of migration issues.
243+
"""
244+
# 1. Get all users with course-related permissions in the new model by filtering
245+
# UserSubjects that are linked to CourseScopes with a valid course overview.
246+
course_subjects = (
247+
UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False)
248+
.select_related("user")
249+
.distinct()
250+
)
251+
252+
roles_with_errors = []
253+
254+
for course_subject in course_subjects:
255+
user = course_subject.user
256+
user_external_key = user.username
257+
258+
# 2. Get all role assignments for the user
259+
role_assignments = get_user_role_assignments(user_external_key=user_external_key)
260+
261+
for assignment in role_assignments:
262+
scope = assignment.scope.external_key
263+
264+
course_overview = assignment.scope.get_object()
265+
266+
for role in assignment.roles:
267+
# We are only interested in course-related scopes and roles
268+
if not scope.startswith("course-v1:"):
269+
continue
270+
271+
# Map new roles back to legacy roles
272+
role_to_legacy_role = {
273+
COURSE_ADMIN.external_key: "instructor",
274+
COURSE_STAFF.external_key: "staff",
275+
COURSE_LIMITED_STAFF.external_key: "limited_staff",
276+
COURSE_DATA_RESEARCHER.external_key: "data_researcher",
277+
}
278+
279+
legacy_role = role_to_legacy_role.get(role.external_key)
280+
if legacy_role is None:
281+
logger.error(f"Unknown role: {role} for User: {user_external_key}")
282+
roles_with_errors.append((user_external_key, role.external_key, scope))
283+
continue
284+
285+
try:
286+
# Create legacy CourseAccessRole entry
287+
CourseAccessRole.objects.get_or_create(
288+
user=user,
289+
org=course_overview.org,
290+
course_id=scope,
291+
role=legacy_role,
292+
)
293+
except Exception as e: # pylint: disable=broad-exception-caught
294+
logger.error(
295+
f"Error creating CourseAccessRole for User: "
296+
f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}"
297+
)
298+
roles_with_errors.append((user_external_key, role.external_key, scope))
299+
continue
300+
301+
# If we successfully created the legacy role, we can unassign the new role
302+
if delete_after_migration:
303+
batch_unassign_role_from_users(
304+
users=[user_external_key],
305+
role_external_key=role.external_key,
306+
scope_external_key=scope,
307+
)
308+
return roles_with_errors
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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
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+
31+
def handle(self, *args, **options):
32+
delete_after_migration = options["delete"]
33+
34+
self.stdout.write(self.style.WARNING("Starting legacy → Authz migration..."))
35+
36+
try:
37+
with transaction.atomic():
38+
errors = migrate_legacy_course_roles_to_authz(
39+
CourseAccessRole=CourseAccessRole,
40+
delete_after_migration=False, # control deletion here instead
41+
)
42+
43+
if errors:
44+
self.stdout.write(self.style.ERROR(f"Migration completed with {len(errors)} errors."))
45+
else:
46+
self.stdout.write(self.style.SUCCESS("Migration completed successfully with no errors."))
47+
48+
# Handle deletion separately for safety
49+
if delete_after_migration:
50+
confirm = input(
51+
"Are you sure you want to delete successfully migrated legacy roles? Type 'yes' to continue: "
52+
)
53+
54+
if confirm != "yes":
55+
self.stdout.write(self.style.WARNING("Deletion aborted."))
56+
return
57+
58+
migrated_ids = [p.id for p in CourseAccessRole.objects.all() if p not in errors]
59+
60+
CourseAccessRole.objects.filter(id__in=migrated_ids).delete()
61+
62+
self.stdout.write(self.style.SUCCESS("Legacy roles deleted successfully."))
63+
64+
except Exception as exc:
65+
self.stdout.write(self.style.ERROR(f"Migration failed due to unexpected error: {exc}"))
66+
raise
67+
68+
self.stdout.write(self.style.SUCCESS("Done."))
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Django management command to rollback course authoring roles from the new Authz (Casbin-based)
3+
authorization system back to the legacy CourseAccessRole model.
4+
"""
5+
6+
from django.core.management.base import BaseCommand
7+
from django.db import transaction
8+
9+
from openedx_authz.engine.utils import migrate_authz_to_legacy_course_roles
10+
from openedx_authz.models.subjects import UserSubject
11+
12+
try:
13+
from common.djangoapps.student.models import CourseAccessRole
14+
except ImportError:
15+
CourseAccessRole = None # type: ignore
16+
17+
18+
class Command(BaseCommand):
19+
"""
20+
Django command to rollback course authoring roles
21+
from the new Authz system back to legacy CourseAccessRole.
22+
"""
23+
24+
help = "Rollback Authz course authoring roles to legacy CourseAccessRole."
25+
26+
def add_arguments(self, parser):
27+
parser.add_argument(
28+
"--delete",
29+
action="store_true",
30+
help="Delete Authz role assignments after successful rollback.",
31+
)
32+
33+
def handle(self, *args, **options):
34+
delete_after_migration = options["delete"]
35+
36+
self.stdout.write(self.style.WARNING("Starting Authz → Legacy rollback migration..."))
37+
38+
try:
39+
with transaction.atomic():
40+
errors = migrate_authz_to_legacy_course_roles(
41+
CourseAccessRole=CourseAccessRole,
42+
UserSubject=UserSubject,
43+
delete_after_migration=False, # control deletion here
44+
)
45+
46+
if errors:
47+
self.stdout.write(self.style.ERROR(f"Rollback completed with {len(errors)} errors."))
48+
else:
49+
self.stdout.write(self.style.SUCCESS("Rollback completed successfully with no errors."))
50+
51+
# Handle deletion separately for safety
52+
if delete_after_migration:
53+
confirm = input(
54+
"Are you sure you want to remove the new Authz role "
55+
"assignments after rollback? Type 'yes' to continue: "
56+
)
57+
58+
if confirm != "yes":
59+
self.stdout.write(self.style.WARNING("Deletion aborted."))
60+
return
61+
62+
# Re-run with deletion enabled
63+
migrate_authz_to_legacy_course_roles(
64+
CourseAccessRole=CourseAccessRole,
65+
UserSubject=UserSubject,
66+
delete_after_migration=True,
67+
)
68+
69+
self.stdout.write(self.style.SUCCESS("Authz role assignments removed successfully."))
70+
71+
except Exception as exc:
72+
self.stdout.write(self.style.ERROR(f"Rollback failed due to unexpected error: {exc}"))
73+
raise
74+
75+
self.stdout.write(self.style.SUCCESS("Done."))
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 4.2.24 on 2026-02-17 22:31
2+
3+
import logging
4+
5+
from django.db import migrations
6+
7+
from openedx_authz.engine.utils import migrate_legacy_course_roles_to_authz
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def _log_migration_errors(permissions_with_errors: list) -> None:
13+
"""
14+
Log the permissions that could not be migrated during the migration process.
15+
Args:
16+
permissions_with_errors (list): List of CourseAccessRole instances that failed to migrate.
17+
"""
18+
logger.error(
19+
f"Migration completed with errors for {len(permissions_with_errors)} permissions.\n"
20+
"The following permissions could not be migrated:"
21+
)
22+
for permission in permissions_with_errors:
23+
logger.error(
24+
"Role: %s, %sCourse: %s",
25+
permission.role,
26+
f"User: {permission.user.username}, " if permission.user else "",
27+
permission.course_id,
28+
)
29+
30+
31+
def apply_migrate_legacy_course_roles_to_authz(apps, schema_editor):
32+
"""
33+
Wrapper to run the migration using the historical version of the CourseAccessRole model.
34+
"""
35+
# CourseAccessRole model from the student app, here is where the legacy course roles are stored
36+
try:
37+
CourseAccessRole = apps.get_model("student", "CourseAccessRole")
38+
except LookupError:
39+
# Don't run the migration where the student app is not installed, like during development.
40+
logger.warning("CourseAccessRole model not found. Skipping migration.")
41+
return
42+
43+
permissions_with_errors = migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migration=True)
44+
45+
if permissions_with_errors:
46+
_log_migration_errors(permissions_with_errors)
47+
48+
49+
class Migration(migrations.Migration):
50+
dependencies = [
51+
("openedx_authz", "0007_coursescope"),
52+
]
53+
54+
operations = [
55+
migrations.RunPython(apply_migrate_legacy_course_roles_to_authz),
56+
]

0 commit comments

Comments
 (0)