Skip to content

Commit f93cf1d

Browse files
Merge branch 'main' into jd/validate-users
2 parents 88a7f45 + bc639d2 commit f93cf1d

15 files changed

Lines changed: 614 additions & 302 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Added
2121
=====
2222

2323
* Add ``users/validate`` endpoint for bulk validation of user identifiers (usernames or emails).
24+
* Add org-wide support to migration commands for forward and backward migration of course authoring permissions.
2425

2526
1.5.0 - 2026-04-09
2627
******************

Makefile

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ docs: ## generate Sphinx HTML documentation, including API docs
3333
$(BROWSER)docs/_build/html/index.html
3434

3535
# Define PIP_COMPILE_OPTS=-v to get more information during make upgrade.
36-
PIP_COMPILE = pip-compile $(PIP_COMPILE_OPTS)
36+
PIP_COMPILE = pip-compile --rebuild $(PIP_COMPILE_OPTS)
3737

38-
compile-requirements: ## compile the requirements/*.txt files with the latest packages satisfying requirements/*.in
38+
compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade
39+
compile-requirements: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in
3940
pip install -qr requirements/pip-tools.txt
4041
pip install -qr requirements/pip.txt
41-
pip-compile -v ${COMPILE_OPTS} --allow-unsafe --rebuild -o requirements/pip.txt requirements/pip.in
42-
pip-compile -v ${COMPILE_OPTS} -o requirements/pip-tools.txt requirements/pip-tools.in
42+
# Make sure to compile files after any other files they include!
43+
$(PIP_COMPILE) --allow-unsafe -o requirements/pip.txt requirements/pip.in
44+
$(PIP_COMPILE) -o requirements/pip-tools.txt requirements/pip-tools.in
4345
pip install -qr requirements/pip.txt
4446
pip install -qr requirements/pip-tools.txt
4547
$(PIP_COMPILE) -o requirements/base.txt requirements/base.in
@@ -53,8 +55,7 @@ compile-requirements: ## compile the requirements/*.txt files with the latest pa
5355
mv requirements/test.tmp requirements/test.txt
5456

5557
upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in
56-
pip install -qr requirements/pip-tools.txt
57-
$(MAKE) compile-requirements COMPILE_OPTS="--upgrade"
58+
make compile-requirements PIP_COMPILE_OPTS="--upgrade"
5859

5960
quality: ## check coding style with pycodestyle and pylint
6061
tox -e quality

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

openedx_authz/rest_api/v1/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import edx_api_doc_tools as apidocs
1111
from django.contrib.auth import get_user_model
12+
from django.db.models import QuerySet
1213
from django.http import HttpRequest
1314
from django.utils.decorators import method_decorator
1415
from edx_api_doc_tools import schema_for
@@ -534,13 +535,16 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView):
534535
}
535536
"""
536537

537-
queryset = Organization.objects.filter(active=True).order_by("name")
538538
serializer_class = OrganizationSerializer
539539
pagination_class = AuthZAPIViewPagination
540540
filter_backends = [filters.SearchFilter]
541541
search_fields = ["name", "short_name"]
542542
permission_classes = [AnyScopePermission]
543543

544+
def get_queryset(self) -> QuerySet:
545+
"""Return active organizations ordered by name."""
546+
return Organization.objects.filter(active=True).order_by("name")
547+
544548

545549
@view_auth_classes()
546550
class TeamMembersAPIView(APIView):

0 commit comments

Comments
 (0)