| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent d83c598 commit 0f57cf2
15 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -26,14 +26,14 @@ | |||
| 26 | 26 | from openedx_filters.content_authoring.filters import LMSPageURLRequested | |
| 27 | 27 | from pymongo import ASCENDING, DESCENDING | |
| 28 | 28 | ||
| 29 | + from common.djangoapps.student.roles import enable_authz_course_authoring | ||
| 29 | 30 | from common.djangoapps.util.date_utils import get_default_time_display | |
| 30 | 31 | from common.djangoapps.util.json_request import JsonResponse | |
| 31 | 32 | from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission | |
| 32 | 33 | from openedx.core.djangoapps.authz.decorators import user_has_course_permission | |
| 33 | 34 | from openedx.core.djangoapps.contentserver.caching import del_cached_content | |
| 34 | 35 | from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers | |
| 35 | 36 | from openedx.core.djangoapps.user_api.models import UserPreference | |
| 36 | - from openedx.core.toggles import enable_authz_course_authoring | ||
| 37 | 37 | from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order | |
| 38 | 38 | from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order | |
| 39 | 39 | from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -5,7 +5,7 @@ | |||
| 5 | 5 | from rest_framework import serializers | |
| 6 | 6 | ||
| 7 | 7 | from cms.djangoapps.contentstore import toggles | |
| 8 | - from openedx.core import toggles as core_toggles | ||
| 8 | + from common.djangoapps.student.roles import enable_authz_course_authoring | ||
| 9 | 9 | ||
| 10 | 10 | ||
| 11 | 11 | class CourseWaffleFlagsSerializer(serializers.Serializer): | |
@@ -225,4 +225,4 @@ def get_enable_authz_course_authoring(self, obj): | |||
| 225 | 225 | Method to get the authz.enable_course_authoring waffle flag | |
| 226 | 226 | """ | |
| 227 | 227 | course_key = self.get_course_key() | |
| 228 | - return core_toggles.enable_authz_course_authoring(course_key) | ||
| 228 | + return enable_authz_course_authoring(course_key) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -13,9 +13,9 @@ | |||
| 13 | 13 | from django.test import override_settings | |
| 14 | 14 | from django.test.client import RequestFactory | |
| 15 | 15 | from django.urls import reverse | |
| 16 | + from edx_toggles.toggles.testutils import override_waffle_flag | ||
| 16 | 17 | from opaque_keys.edx.keys import CourseKey | |
| 17 | 18 | from opaque_keys.edx.locator import CourseLocator | |
| 18 | - from openedx_authz.constants.roles import COURSE_EDITOR | ||
| 19 | 19 | from organizations.api import add_organization, get_course_organizations, get_organization_by_short_name | |
| 20 | 20 | from organizations.exceptions import InvalidOrganizationException | |
| 21 | 21 | from organizations.models import Organization | |
@@ -34,6 +34,7 @@ | |||
| 34 | 34 | from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, OrgContentCreatorRole | |
| 35 | 35 | from common.djangoapps.student.tests.factories import AdminFactory, UserFactory | |
| 36 | 36 | from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin | |
| 37 | + from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG | ||
| 37 | 38 | from xmodule.course_block import CourseFields | |
| 38 | 39 | from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase | |
| 39 | 40 | from xmodule.modulestore.tests.factories import CourseFactory | |
@@ -386,119 +387,37 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( | |||
| 386 | 387 | ) | |
| 387 | 388 | ||
| 388 | 389 | ||
| 389 | - class TestCourseHandlerAuthz( | ||
| 390 | - CourseAuthoringAuthzTestMixin, | ||
| 391 | - ModuleStoreTestCase, | ||
| 392 | - ): | ||
| 390 | + @ddt.ddt | ||
| 391 | + class TestCourseHandlerStaffAccess(ModuleStoreTestCase): | ||
| 393 | 392 | """ | |
| 394 | - AuthZ integration tests for course_handler using real RBAC (no mocks). | ||
| 393 | + Tests that global staff can create a course through course_handler with no | ||
| 394 | + course-specific role, covering the GlobalStaff bypass in user_has_role. Course | ||
| 395 | + creation doesn't check AuthZ (see ADR 0027), so this doesn't use the AuthZ mixin. | ||
| 395 | 396 | """ | |
| 396 | 397 | ||
| 397 | 398 | def setUp(self): | |
| 398 | 399 | super().setUp() | |
| 399 | - | ||
| 400 | 400 | self.url = reverse("course_handler") | |
| 401 | + self.staff_user = AdminFactory() | ||
| 402 | + self.staff_client = AjaxEnabledTestClient() | ||
| 403 | + self.staff_client.login(username=self.staff_user.username, password=self.TEST_PASSWORD) | ||
| 401 | 404 | ||
| 402 | - # Create a base course to extract org | ||
| 403 | - self.course = CourseFactory.create() | ||
| 404 | - self.course_key = self.course.id | ||
| 405 | - self.org = self.course_key.org | ||
| 406 | - | ||
| 407 | - # If your policy expects this format, keep it | ||
| 408 | - self.org_key = f"course-v1:{self.org}+*" | ||
| 409 | - | ||
| 410 | - self.authorized_client = AjaxEnabledTestClient() | ||
| 411 | - self.authorized_client.login( | ||
| 412 | - username=self.authorized_user.username, | ||
| 413 | - password=self.password, | ||
| 414 | - ) | ||
| 415 | - | ||
| 416 | - self.unauthorized_client = AjaxEnabledTestClient() | ||
| 417 | - self.unauthorized_client.login( | ||
| 418 | - username=self.unauthorized_user.username, | ||
| 419 | - password=self.password, | ||
| 420 | - ) | ||
| 421 | - self.authorized_staff_client = AjaxEnabledTestClient() | ||
| 422 | - self.authorized_staff_client.login( | ||
| 423 | - username=self.staff_user.username, | ||
| 424 | - password=self.password, | ||
| 425 | - ) | ||
| 426 | - | ||
| 427 | - # ------------------------------------------------------------ | ||
| 428 | - # CREATE COURSE -- Non-staff users and existing Organization | ||
| 429 | - # ------------------------------------------------------------ | ||
| 430 | - @override_settings(DISABLE_COURSE_CREATION=False) | ||
| 431 | - def test_create_course_unauthorized(self): | ||
| 432 | - """ | ||
| 433 | - User without role cannot create course. | ||
| 434 | - """ | ||
| 435 | - | ||
| 436 | - response = self.unauthorized_client.ajax_post(self.url, { | ||
| 437 | - "org": self.org, | ||
| 438 | - "number": "CS101", | ||
| 439 | - "display_name": "Authz Course", | ||
| 440 | - "run": "2026_T1", | ||
| 441 | - }) | ||
| 442 | - | ||
| 443 | - assert response.status_code == 403 | ||
| 444 | - | ||
| 445 | - @override_settings(DISABLE_COURSE_CREATION=False) | ||
| 446 | - def test_create_course_unauthorized_with_role(self): | ||
| 447 | - """ | ||
| 448 | - User with role but without required permission cannot create course. | ||
| 449 | - """ | ||
| 450 | - | ||
| 451 | - self.add_user_to_role_in_course( | ||
| 452 | - self.unauthorized_user, | ||
| 453 | - COURSE_EDITOR.external_key, | ||
| 454 | - "course-v1:someotherorg+*", | ||
| 455 | - ) | ||
| 456 | - | ||
| 457 | - response = self.unauthorized_client.ajax_post(self.url, { | ||
| 458 | - "org": self.org, | ||
| 459 | - "number": "CS101", | ||
| 460 | - "display_name": "Authz Course", | ||
| 461 | - "run": "2026_T1", | ||
| 462 | - }) | ||
| 463 | - | ||
| 464 | - assert response.status_code == 403 | ||
| 465 | - | ||
| 466 | - # ------------------------------------------------------------ | ||
| 467 | - # CREATE COURSE -- Staff users | ||
| 468 | - # Only staff users can create course, and they can do it | ||
| 469 | - # without an org role. | ||
| 470 | - # ------------------------------------------------------------ | ||
| 471 | - def test_create_course_staff(self): | ||
| 405 | + @ddt.data(True, False) | ||
| 406 | + def test_create_course_staff(self, authz_enabled): | ||
| 472 | 407 | """ | |
| 473 | - Staff user can create course. | ||
| 408 | + Staff user can create a course with no prior course-specific role, whether | ||
| 409 | + the AuthZ course-authoring flag is on or off. | ||
| 474 | 410 | """ | |
| 475 | - response = self.authorized_staff_client.ajax_post(self.url, { | ||
| 476 | - "org": self.org, | ||
| 477 | - "number": "CS101", | ||
| 478 | - "display_name": "Authz Course", | ||
| 479 | - "run": "2026_T1", | ||
| 480 | - }) | ||
| 411 | + with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled): | ||
| 412 | + response = self.staff_client.ajax_post(self.url, { | ||
| 413 | + "org": "StaffOrg", | ||
| 414 | + "number": "CS101", | ||
| 415 | + "display_name": "Staff Course", | ||
| 416 | + "run": "2026_T1", | ||
| 417 | + }) | ||
| 481 | 418 | ||
| 482 | 419 | assert response.status_code == 200 | |
| 483 | 420 | ||
| 484 | - # ------------------------------------------------------------ | ||
| 485 | - # FEATURE FLAG | ||
| 486 | - # ------------------------------------------------------------ | ||
| 487 | - @override_settings(DISABLE_COURSE_CREATION=True) | ||
| 488 | - def test_create_course_disabled_by_flag(self): | ||
| 489 | - """ | ||
| 490 | - Even authorized users cannot create course if feature flag is off. | ||
| 491 | - """ | ||
| 492 | - | ||
| 493 | - response = self.authorized_staff_client.ajax_post(self.url, { | ||
| 494 | - "org": self.org, | ||
| 495 | - "number": "CS101", | ||
| 496 | - "display_name": "Authz Course", | ||
| 497 | - "run": "2026_T1", | ||
| 498 | - }) | ||
| 499 | - | ||
| 500 | - assert response.status_code == 403 | ||
| 501 | - | ||
| 502 | 421 | ||
| 503 | 422 | class TestCourseRerunAuthz( | |
| 504 | 423 | CourseAuthoringAuthzTestMixin, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -74,6 +74,7 @@ | |||
| 74 | 74 | GlobalStaff, | |
| 75 | 75 | OrgStaffRole, | |
| 76 | 76 | UserBasedRole, | |
| 77 | + enable_authz_course_authoring, | ||
| 77 | 78 | strict_role_checking, | |
| 78 | 79 | ) | |
| 79 | 80 | from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json | |
@@ -857,7 +858,7 @@ def _get_course_keys_from_platform_scope() -> set[CourseKey]: | |||
| 857 | 858 | if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(): | |
| 858 | 859 | return set(course_keys) | |
| 859 | 860 | ||
| 860 | - return {course_key for course_key in course_keys if core_toggles.enable_authz_course_authoring(course_key)} | ||
| 861 | + return {course_key for course_key in course_keys if enable_authz_course_authoring(course_key)} | ||
| 861 | 862 | ||
| 862 | 863 | ||
| 863 | 864 | def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]) -> set[CourseKey]: | |
@@ -888,15 +889,15 @@ def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]) -> set[CourseKey | |||
| 888 | 889 | ||
| 889 | 890 | for access in authz_scopes: | |
| 890 | 891 | if isinstance(access, CourseOverviewData) and access.course_key: | |
| 891 | - if core_toggles.enable_authz_course_authoring(access.course_key): | ||
| 892 | + if enable_authz_course_authoring(access.course_key): | ||
| 892 | 893 | course_keys.add(access.course_key) | |
| 893 | 894 | elif isinstance(access, OrgCourseOverviewGlobData) and access.org: | |
| 894 | 895 | org_keys.add(access.org) | |
| 895 | 896 | ||
| 896 | 897 | if org_keys: | |
| 897 | 898 | course_keys.update( | |
| 898 | 899 | key for key in _get_course_keys_for_org_scope(org_keys) | |
| 899 | - if core_toggles.enable_authz_course_authoring(key) | ||
| 900 | + if enable_authz_course_authoring(key) | ||
| 900 | 901 | ) | |
| 901 | 902 | ||
| 902 | 903 | return course_keys | |
@@ -1344,7 +1345,7 @@ def rerun_course(user, source_course_key, org, number, run, fields, background=T | |||
| 1344 | 1345 | # is implemented (pre-assigning roles without a CourseOverview). Once resolved, | |
| 1345 | 1346 | # add_instructor can be called unconditionally here and the created_user fallback | |
| 1346 | 1347 | # in get_in_process_course_actions can be removed. | |
| 1347 | - if not core_toggles.enable_authz_course_authoring(destination_course_key): | ||
| 1348 | + if not enable_authz_course_authoring(destination_course_key): | ||
| 1348 | 1349 | add_instructor(destination_course_key, user, user) | |
| 1349 | 1350 | ||
| 1350 | 1351 | # Mark the action as initiated | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -11,7 +11,7 @@ | |||
| 11 | 11 | from django.core.exceptions import PermissionDenied | |
| 12 | 12 | from opaque_keys.edx.locator import LibraryLocator | |
| 13 | 13 | from openedx_authz import api as authz_api | |
| 14 | - from openedx_authz.constants.permissions import COURSES_CREATE_COURSE, COURSES_MANAGE_ADVANCED_SETTINGS | ||
| 14 | + from openedx_authz.constants.permissions import COURSES_MANAGE_ADVANCED_SETTINGS | ||
| 15 | 15 | ||
| 16 | 16 | from common.djangoapps.student.roles import ( | |
| 17 | 17 | CourseBetaTesterRole, | |
@@ -227,9 +227,9 @@ def is_content_creator(user, org): | |||
| 227 | 227 | """ | |
| 228 | 228 | Determine whether a user is allowed to create course content for a given organization. | |
| 229 | 229 | ||
| 230 | - This function abstracts the permission check for course creation. Depending on the | ||
| 231 | - state of the AuthZ feature flag, it delegates the evaluation to either the AuthZ-based | ||
| 232 | - RBAC system or the legacy role-based permission system. | ||
| 230 | + Neither CourseCreatorRole nor OrgContentCreatorRole has a migrated AuthZ equivalent yet | ||
| 231 | + (see ADR 0027), so this always checks the legacy role-based permission system. Once | ||
| 232 | + either role gets a migrated equivalent, this should also check AuthZ, gated on that role. | ||
| 233 | 233 | ||
| 234 | 234 | Args: | |
| 235 | 235 | user (User): The user whose permissions are being evaluated. | |
@@ -238,38 +238,10 @@ def is_content_creator(user, org): | |||
| 238 | 238 | Returns: | |
| 239 | 239 | bool: True if the user has permission to create course content in the given | |
| 240 | 240 | organization, False otherwise. | |
| 241 | - | ||
| 242 | - Notes: | ||
| 243 | - - When AuthZ is enabled, this checks permissions via RBAC policies. | ||
| 244 | - - When AuthZ is disabled, this falls back to legacy Django role checks. | ||
| 245 | - - Course creation may still be blocked by global feature flags (e.g., | ||
| 246 | - DISABLE_COURSE_CREATION), which are enforced downstream. | ||
| 247 | 241 | """ | |
| 248 | - if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(): | ||
| 249 | - return _has_content_creator_access(user, org) | ||
| 250 | 242 | return _has_legacy_content_creator_access(user, org) | |
| 251 | 243 | ||
| 252 | 244 | ||
| 253 | - def _has_content_creator_access(user, org): | ||
| 254 | - """ | ||
| 255 | - Check if the user has content creator access based on AuthZ permissions. | ||
| 256 | - | ||
| 257 | - Returns: | ||
| 258 | - bool: True if the user has platform-wide or org-scoped course creation permission. | ||
| 259 | - """ | ||
| 260 | - if getattr(settings, 'DISABLE_COURSE_CREATION', False): | ||
| 261 | - return False | ||
| 262 | - | ||
| 263 | - scope_keys = ( | ||
| 264 | - authz_api.PlatformCourseOverviewGlobData.build_external_key(), | ||
| 265 | - authz_api.OrgCourseOverviewGlobData.build_external_key(org), | ||
| 266 | - ) | ||
| 267 | - return any( | ||
| 268 | - authz_api.is_user_allowed(user.username, COURSES_CREATE_COURSE.identifier, scope_key) | ||
| 269 | - for scope_key in scope_keys | ||
| 270 | - ) | ||
| 271 | - | ||
| 272 | - | ||
| 273 | 245 | def _has_legacy_content_creator_access(user, org): | |
| 274 | 246 | """ | |
| 275 | 247 | Check if the user has the role to create content. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -21,7 +21,7 @@ | |||
| 21 | 21 | from common.djangoapps.student.models import CourseAccessRole | |
| 22 | 22 | from common.djangoapps.student.signals.signals import emit_course_access_role_added, emit_course_access_role_removed | |
| 23 | 23 | from openedx.core.lib.cache_utils import get_cache | |
| 24 | - from openedx.core.toggles import enable_authz_course_authoring | ||
| 24 | + from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG | ||
| 25 | 25 | ||
| 26 | 26 | log = logging.getLogger(__name__) | |
| 27 | 27 | ||
@@ -43,6 +43,24 @@ def get_legacy_role_from_authz_role(authz_role: str) -> str: | |||
| 43 | 43 | return next((k for k, v in authz_roles.LEGACY_COURSE_ROLE_EQUIVALENCES.items() if v == authz_role), None) | |
| 44 | 44 | ||
| 45 | 45 | ||
| 46 | + def enable_authz_course_authoring(course_key: CourseKey | None = None, role: str | None = None) -> bool: | ||
| 47 | + """ | ||
| 48 | + True only if the authz.enable_course_authoring waffle flag is enabled and, when `role` is | ||
| 49 | + given, that role has a migrated authz equivalent. | ||
| 50 | + | ||
| 51 | + The migration only covers a subset of legacy roles (see LEGACY_COURSE_ROLE_EQUIVALENCES | ||
| 52 | + and ADR 0027), so an unmigrated role must keep resolving to the legacy path even with the | ||
| 53 | + flag on. Most callers have no specific role in scope and can omit `role`. Pass it when | ||
| 54 | + acting on a specific legacy role, so a role like course_creator_group falls back to legacy | ||
| 55 | + instead of crashing or being denied by authz (see openedx-authz#353, #354). | ||
| 56 | + """ | ||
| 57 | + if not AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key): | ||
| 58 | + return False | ||
| 59 | + if role is not None and get_authz_role_from_legacy_role(role) is None: | ||
| 60 | + return False | ||
| 61 | + return True | ||
| 62 | + | ||
| 63 | + | ||
| 46 | 64 | def authz_add_role(user: User, authz_role: str, course_key: str): | |
| 47 | 65 | """ | |
| 48 | 66 | Add a user's role in a course if not already added. | |
@@ -520,7 +538,7 @@ def add_users(self, *users): | |||
| 520 | 538 | """ | |
| 521 | 539 | Add the supplied django users to this role. | |
| 522 | 540 | """ | |
| 523 | - if enable_authz_course_authoring(self.course_key): | ||
| 541 | + if enable_authz_course_authoring(self.course_key, role=self._role_name): | ||
| 524 | 542 | self._authz_add_users(users) | |
| 525 | 543 | else: | |
| 526 | 544 | self._legacy_add_users(users) | |
@@ -561,7 +579,7 @@ def remove_users(self, *users): | |||
| 561 | 579 | """ | |
| 562 | 580 | Remove the supplied django users from this role. | |
| 563 | 581 | """ | |
| 564 | - if enable_authz_course_authoring(self.course_key): | ||
| 582 | + if enable_authz_course_authoring(self.course_key, role=self._role_name): | ||
| 565 | 583 | self._authz_remove_users(users) | |
| 566 | 584 | else: | |
| 567 | 585 | self._legacy_remove_users(users) | |
@@ -599,7 +617,7 @@ def users_with_role(self): | |||
| 599 | 617 | """ | |
| 600 | 618 | Return a django QuerySet for all of the users with this role | |
| 601 | 619 | """ | |
| 602 | - if enable_authz_course_authoring(self.course_key): | ||
| 620 | + if enable_authz_course_authoring(self.course_key, role=self._role_name): | ||
| 603 | 621 | return self._authz_users_with_role() | |
| 604 | 622 | else: | |
| 605 | 623 | return self._legacy_users_with_role() | |
@@ -628,7 +646,7 @@ def get_orgs_for_user(self, user): | |||
| 628 | 646 | """ | |
| 629 | 647 | Returns a list of org short names for the user with given role. | |
| 630 | 648 | """ | |
| 631 | - if enable_authz_course_authoring(self.course_key): | ||
| 649 | + if enable_authz_course_authoring(self.course_key, role=self._role_name): | ||
| 632 | 650 | return self._authz_get_orgs_for_user(user) | |
| 633 | 651 | else: | |
| 634 | 652 | return self._legacy_get_orgs_for_user(user) | |
@@ -642,7 +660,7 @@ def has_org_for_user(self, user: User, org: str | None = None) -> bool: | |||
| 642 | 660 | org: optional org to check against access to role, | |
| 643 | 661 | if not specified, will return True if the user has access to at least one org | |
| 644 | 662 | """ | |
| 645 | - if enable_authz_course_authoring(self.course_key): | ||
| 663 | + if enable_authz_course_authoring(self.course_key, role=self._role_name): | ||
| 646 | 664 | orgs_with_role = self.get_orgs_for_user(user) | |
| 647 | 665 | if org: | |
| 648 | 666 | return org in orgs_with_role | |
| Back | FazBrowse Home | New Git URL |
0 commit comments