Skip to content

Commit 1117c3c

Browse files
refactor: use only runtime utilities to filter by course namespace (glob or specific key)
1 parent 0675eea commit 1117c3c

3 files changed

Lines changed: 102 additions & 206 deletions

File tree

openedx_authz/api/roles.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"get_subject_role_assignments",
4242
"get_subject_role_assignments_for_role_in_scope",
4343
"get_subject_role_assignments_in_scope",
44+
"get_all_role_assignments_per_scope_type",
4445
"unassign_role_from_subject_in_scope",
4546
"unassign_subject_from_all_roles",
4647
]
@@ -527,3 +528,22 @@ def unassign_subject_from_all_roles(subject: SubjectData) -> bool:
527528
"""
528529
enforcer = AuthzEnforcer.get_enforcer()
529530
return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key)
531+
532+
533+
def get_all_role_assignments_per_scope_type(scope_type: type[ScopeData]) -> list[RoleAssignmentData]:
534+
"""Get all role assignments for a specific scope type.
535+
536+
Loads all grouping policies from the enforcer and filters in Python. Casbin policies
537+
store full scope keys (e.g., 'course-v1^course-v1:Org+Course+Run'), so there is no
538+
way to query by scope type directly so the filtering must happen here.
539+
540+
Args:
541+
scope_type: A ScopeData subclass (not an instance) used to match by NAMESPACE.
542+
543+
Returns:
544+
list[RoleAssignmentData]: All assignments whose scope matches the given scope type.
545+
"""
546+
return [
547+
role_assignment for role_assignment in get_role_assignments()
548+
if role_assignment.scope.NAMESPACE == scope_type.NAMESPACE
549+
]

openedx_authz/engine/utils.py

Lines changed: 68 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
from casbin import Enforcer
1111

1212
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData
13+
from openedx_authz.api.roles import get_all_role_assignments_per_scope_type
1314
from openedx_authz.api.users import (
1415
assign_role_to_user_in_scope,
1516
batch_assign_role_to_users_in_scope,
1617
batch_unassign_role_from_users,
17-
get_user_role_assignments,
1818
)
1919
from openedx_authz.constants.roles import (
2020
LEGACY_COURSE_ROLE_EQUIVALENCES,
@@ -203,6 +203,8 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
203203
raise ValueError(
204204
"At least one of course_id_list or org_id must be provided to limit the scope of the migration."
205205
)
206+
207+
# TODO: not sure if we should keep the startswith here
206208
course_access_role_filter = {}
207209

208210
if org_id:
@@ -286,6 +288,12 @@ def migrate_authz_to_legacy_course_roles(
286288
This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
287289
for rollback purposes in case of migration issues.
288290
291+
To build each CourseAccessRole entry, the function needs:
292+
- A user: resolved from role assignments in scopes linked to courses.
293+
- A scope: either a CourseOverviewData (course-level) or OrgCourseOverviewGlobData (org-level glob),
294+
filtered by course_id or org_id if provided.
295+
- A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES.
296+
289297
param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
290298
is intended to run within a Django migration context, where direct model imports can cause issues.
291299
param user_subject_model: It should be the UserSubject model. This is passed in because the function
@@ -300,70 +308,77 @@ def migrate_authz_to_legacy_course_roles(
300308
"At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration."
301309
)
302310

303-
# 1. Get all users with course-related permissions in the new model by filtering
304-
# UserSubjects that are linked to CourseScopes with a valid course overview.
305-
course_subject_filter = {
306-
"casbin_rules__scope__coursescope__course_overview__isnull": False,
307-
}
311+
# CourseOverviewData and OrgCourseOverviewGlobData share the same namespace,
312+
# so filtering by CourseOverviewData captures both course-level and org-level glob assignments.
313+
role_assignments = get_all_role_assignments_per_scope_type(scope_type=CourseOverviewData)
308314

315+
# Two cases here:
316+
# 1. If org_id is provided, we filter by org_id which will include both org-level glob scopes and course-level scopes linked to that org
317+
# 2. If only course_id_list is provided, we filter by course_id which will include only course-level scopes linked to those course_ids since
318+
# org-level glob scopes don't have course_id in their scope object
309319
if org_id:
310-
course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id
320+
role_assignments = [
321+
role_assignment
322+
for role_assignment in role_assignments
323+
if role_assignment.scope.org == org_id
324+
]
311325

312326
if course_id_list and not org_id:
313-
# Only filter by course_id if org_id is not provided,
314-
# otherwise we will filter by org_id which is more efficient
315-
course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list
316-
317-
course_subjects = user_subject_model.objects.filter(**course_subject_filter).select_related("user").distinct()
327+
role_assignments = [
328+
role_assignment
329+
for role_assignment in role_assignments
330+
if isinstance(role_assignment.scope, CourseOverviewData)
331+
and role_assignment.scope.external_key in course_id_list
332+
]
318333

319334
roles_with_errors = []
320335
roles_with_no_errors = []
321336
unassignments = defaultdict(list)
322337

323-
for course_subject in course_subjects:
324-
user = course_subject.user
325-
user_external_key = user.username
338+
for role_assignment in role_assignments:
339+
340+
# Per valid role assignment, create corresponding CourseAccessRole entry
341+
try:
342+
user_external_key = role_assignment.subject.external_key
343+
role_external_key = role_assignment.roles[0].external_key
344+
scope_external_key = role_assignment.scope.external_key
345+
346+
course_access_role_kwargs = {
347+
"user": user_subject_model.objects.get(user__username=user_external_key).user,
348+
"role": COURSE_ROLE_EQUIVALENCES[role_external_key],
349+
}
350+
351+
# Here we prioritize course_id over org for scope since course-level scope is more specific
352+
# and also both are not needed to create a valid CourseAccessRole entry
353+
if isinstance(role_assignment.scope, CourseOverviewData):
354+
course_access_role_kwargs["course_id"] = scope_external_key
355+
elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData):
356+
course_access_role_kwargs["org"] = role_assignment.scope.org
357+
else:
358+
logger.error(
359+
f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with scope: {scope_external_key}"
360+
)
361+
roles_with_errors.append(role_assignment)
362+
continue
363+
364+
course_access_role_model.objects.create(**course_access_role_kwargs)
365+
roles_with_no_errors.append(role_assignment)
326366

327-
# 2. Get all role assignments for the user
328-
role_assignments = get_user_role_assignments(user_external_key=user_external_key)
367+
logger.info(
368+
f"Successfully rolled back RoleAssignment for User: {user_external_key} "
369+
f"in Role: {role_external_key} and Scope: {scope_external_key} "
370+
f"to legacy CourseAccessRole entry."
371+
)
329372

330-
for assignment in role_assignments:
331-
if not isinstance(assignment.scope, CourseOverviewData):
332-
logger.error(f"Skipping role assignment for User: {user_external_key} due to missing course scope.")
333-
continue
373+
if delete_after_migration:
374+
unassignments[(role_external_key, scope_external_key)].append(user_external_key)
334375

335-
scope = assignment.scope.external_key
336-
337-
course_overview = assignment.scope.get_object()
338-
339-
for role in assignment.roles:
340-
legacy_role = COURSE_ROLE_EQUIVALENCES.get(role.external_key)
341-
if legacy_role is None:
342-
logger.error(f"Unknown role: {role} for User: {user_external_key}")
343-
roles_with_errors.append((user_external_key, role.external_key, scope))
344-
continue
345-
346-
try:
347-
# Create legacy CourseAccessRole entry
348-
course_access_role_model.objects.get_or_create(
349-
user=user,
350-
org=course_overview.org,
351-
course_id=scope,
352-
role=legacy_role,
353-
)
354-
roles_with_no_errors.append((user_external_key, role.external_key, scope))
355-
except Exception as e: # pylint: disable=broad-exception-caught
356-
logger.error(
357-
f"Error creating CourseAccessRole for User: "
358-
f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}"
359-
)
360-
roles_with_errors.append((user_external_key, role.external_key, scope))
361-
continue
362-
363-
# If we successfully created the legacy role, we can add this role assignment
364-
# to the unassignment list if delete_after_migration is True
365-
if delete_after_migration:
366-
unassignments[(role.external_key, scope)].append(user_external_key)
376+
except Exception as e:
377+
logger.error(
378+
f"Error rolling back RoleAssignment for User: {role_assignment.subject.external_key} "
379+
f"in Role: {role_assignment.roles[0].external_key} and Scope: {role_assignment.scope.external_key}: {e}"
380+
)
381+
roles_with_errors.append(role_assignment)
367382

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

0 commit comments

Comments
 (0)