|
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import logging |
| 8 | +from collections import defaultdict |
8 | 9 |
|
9 | 10 | from casbin import Enforcer |
10 | 11 |
|
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 | +) |
13 | 25 |
|
14 | 26 | logger = logging.getLogger(__name__) |
15 | 27 |
|
16 | 28 | GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] |
17 | 29 |
|
18 | 30 |
|
| 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 | + |
19 | 35 | def migrate_policy_between_enforcers( |
20 | 36 | source_enforcer: Enforcer, |
21 | 37 | target_enforcer: Enforcer, |
@@ -151,3 +167,203 @@ def migrate_legacy_permissions(ContentLibraryPermission): |
151 | 167 | ) |
152 | 168 |
|
153 | 169 | 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 |
0 commit comments