diff --git a/README.rst b/README.rst index f571c2c48a8b02465c012df084cf93d1bf660166..dfbcb5d6b55792ae64bd7e2cc7e544e43f3e2cba 100644 --- a/README.rst +++ b/README.rst @@ -318,9 +318,21 @@ Please do not report security issues in public. Send security concerns via email Changelog ========= +3.0.0 - 2021-06-16 +------------------- + +* Rename `CourseEditLTIFieldsEnabledFlag` to `CourseAllowPIISharingInLTIFlag` + to highlight its increased scope. +* Use `CourseAllowPIISharingInLTIFlag` for LTI1.3 in lieu of the current + `CourseWaffleFlag`. + + 2.11.0 - 2021-06-10 ------------------- +* NOTE: This release requires a corresponding change in edx-platform that was + implemented in https://github.com/edx/edx-platform/pull/27529 + As such, this release cannot be installed in releases before Maple. * Move ``CourseEditLTIFieldsEnabledFlag`` from ``edx-platform`` to this repo while retaining data from existing model. diff --git a/lti_consumer/admin.py b/lti_consumer/admin.py index c5fae563f0d5192b66abb631c355ce5d9dea93e2..c6360bb379f0d14c95502eae33f7062ceebb481c 100644 --- a/lti_consumer/admin.py +++ b/lti_consumer/admin.py @@ -4,9 +4,9 @@ Admin views for LTI related models. from config_models.admin import KeyedConfigurationModelAdmin from django.contrib import admin -from lti_consumer.forms import CourseEditLTIFieldsEnabledAdminForm +from lti_consumer.forms import CourseAllowPIISharingInLTIAdminForm from lti_consumer.models import ( - CourseEditLTIFieldsEnabledFlag, + CourseAllowPIISharingInLTIFlag, LtiAgsLineItem, LtiAgsScore, LtiConfiguration, @@ -23,12 +23,12 @@ class LtiConfigurationAdmin(admin.ModelAdmin): readonly_fields = ('location', 'config_id') -class CourseEditLTIFieldsEnabledFlagAdmin(KeyedConfigurationModelAdmin): +class CourseAllowPIISharingInLTIFlagAdmin(KeyedConfigurationModelAdmin): """ - Admin for LTI Fields Editing feature on course-by-course basis. + Admin for enabling PII Sharing in LTI on course-by-course basis. Allows searching by course id. """ - form = CourseEditLTIFieldsEnabledAdminForm + form = CourseAllowPIISharingInLTIAdminForm search_fields = ['course_id'] fieldsets = ( (None, { @@ -38,7 +38,7 @@ class CourseEditLTIFieldsEnabledFlagAdmin(KeyedConfigurationModelAdmin): ) -admin.site.register(CourseEditLTIFieldsEnabledFlag, CourseEditLTIFieldsEnabledFlagAdmin) +admin.site.register(CourseAllowPIISharingInLTIFlag, CourseAllowPIISharingInLTIFlagAdmin) admin.site.register(LtiConfiguration, LtiConfigurationAdmin) admin.site.register(LtiAgsLineItem) admin.site.register(LtiAgsScore) diff --git a/lti_consumer/api.py b/lti_consumer/api.py index 9958ecc6294eee78defdb1df145193277378b178..ea552b81aecd5217c97f978368b05fcb821ae2bb 100644 --- a/lti_consumer/api.py +++ b/lti_consumer/api.py @@ -7,8 +7,10 @@ return plaintext to allow easy testing/mocking. import json +from opaque_keys.edx.keys import CourseKey + from .exceptions import LtiError -from .models import LtiConfiguration, LtiDlContentItem +from .models import CourseAllowPIISharingInLTIFlag, LtiConfiguration, LtiDlContentItem from .utils import ( get_lti_deeplinking_content_url, get_lms_lti_keyset_link, @@ -189,7 +191,20 @@ def get_deep_linking_data(deep_linking_id, config_id=None, block=None): # Retrieve LTI Configuration lti_config = _get_lti_config(config_id, block) # Only filter DL content item from content item set in the same LTI configuration. - # This avoid a malicious user to input a random LTI id and perform LTI DL - # content launches outsite the scope of it's configuration. + # This avoids a malicious user to input a random LTI id and perform LTI DL + # content launches outside the scope of its configuration. content_item = lti_config.ltidlcontentitem_set.get(pk=deep_linking_id) return content_item.attributes + + +def get_lti_pii_sharing_state_for_course(course_key: CourseKey) -> bool: + """ + Returns the status of PII sharing for the provided course. + + Args: + course_key (CourseKey): Course key for the course to check for PII sharing + + Returns: + bool: The state of PII sharing for this course for LTI. + """ + return CourseAllowPIISharingInLTIFlag.current(course_key).enabled diff --git a/lti_consumer/forms.py b/lti_consumer/forms.py index 7ea866ad85b6c7e6e2827c810f786189eb7a8c69..c4d8bbe2a6b45ba55370468edad8ed50f54513ef 100644 --- a/lti_consumer/forms.py +++ b/lti_consumer/forms.py @@ -7,19 +7,19 @@ import logging from django import forms -from lti_consumer.models import CourseEditLTIFieldsEnabledFlag +from lti_consumer.models import CourseAllowPIISharingInLTIFlag from lti_consumer.plugin.compat import clean_course_id log = logging.getLogger(__name__) -class CourseEditLTIFieldsEnabledAdminForm(forms.ModelForm): +class CourseAllowPIISharingInLTIAdminForm(forms.ModelForm): """ Form for LTI consumer course-specific configuration to verify the course id. """ class Meta: - model = CourseEditLTIFieldsEnabledFlag + model = CourseAllowPIISharingInLTIFlag fields = '__all__' def clean_course_id(self): diff --git a/lti_consumer/migrations/0012_rename_courseeditltifieldsenabledflag_model.py b/lti_consumer/migrations/0012_rename_courseeditltifieldsenabledflag_model.py new file mode 100644 index 0000000000000000000000000000000000000000..1a86777b5e0d0a416e2b78fcd074781fc621f68c --- /dev/null +++ b/lti_consumer/migrations/0012_rename_courseeditltifieldsenabledflag_model.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2021-06-16 12:26 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('lti_consumer', '0011_courseeditltifieldsenabledflag'), + ] + + operations = [ + migrations.RenameModel( + old_name='CourseEditLTIFieldsEnabledFlag', + new_name='CourseAllowPIISharingInLTIFlag', + ), + ] diff --git a/lti_consumer/models.py b/lti_consumer/models.py index dec339eab7712fb50c1dcb207e8eb4c2056f8a2d..92e9701ada0731fb5bcddfd6d0b73ae59708d012 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -11,6 +11,7 @@ from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField from Cryptodome.PublicKey import RSA from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField +from opaque_keys.edx.keys import CourseKey from config_models.models import ConfigurationModel # LTI 1.1 @@ -329,6 +330,22 @@ class LtiConfiguration(models.Model): return self._get_lti_1p1_consumer() + @property + def pii_share_username(self): + return self.lti_config.get('pii_share_username', False) # pylint: disable=no-member + + @pii_share_username.setter + def pii_share_username(self, value): + self.lti_config['pii_share_username'] = value # pylint: disable=unsupported-assignment-operation + + @property + def pii_share_email(self): + return self.lti_config.get('pii_share_email', False) # pylint: disable=no-member + + @pii_share_email.setter + def pii_share_email(self, value): + self.lti_config['pii_share_email'] = value # pylint: disable=unsupported-assignment-operation + def __str__(self): return f"[{self.config_store}] {self.version} - {self.location}" @@ -535,10 +552,14 @@ class LtiDlContentItem(models.Model): app_label = 'lti_consumer' -class CourseEditLTIFieldsEnabledFlag(ConfigurationModel): +class CourseAllowPIISharingInLTIFlag(ConfigurationModel): """ - Enables the editing of "request username" and "request email" fields - of LTI consumer for a specific course. + Enables the sharing of PII via LTI for the specific course. + + Depending on where it's used, further action might be needed to actually + enable sharing PII. For instance, in the LTI XBlock setting this flag + will allow editing the "request username" and "request email" fields, which + will also need to be set to actually share PII. .. no_pii: """ @@ -548,7 +569,7 @@ class CourseEditLTIFieldsEnabledFlag(ConfigurationModel): @classmethod @request_cached - def lti_access_to_learners_editable(cls, course_id, is_already_sharing_learner_info): + def lti_access_to_learners_editable(cls, course_id: CourseKey, is_already_sharing_learner_info: bool) -> bool: """ Looks at the currently active configuration model to determine whether the feature that enables editing of "request username" and "request email" @@ -563,13 +584,13 @@ class CourseEditLTIFieldsEnabledFlag(ConfigurationModel): is_already_sharing_learner_info (bool): indicates whether LTI consumer is already sharing edX learner username/email. """ - course_specific_config = (CourseEditLTIFieldsEnabledFlag.objects + course_specific_config = (CourseAllowPIISharingInLTIFlag.objects .filter(course_id=course_id) .order_by('-change_date') .first()) if is_already_sharing_learner_info and not course_specific_config: - CourseEditLTIFieldsEnabledFlag.objects.create(course_id=course_id, enabled=True) + CourseAllowPIISharingInLTIFlag.objects.create(course_id=course_id, enabled=True) return True return course_specific_config.enabled if course_specific_config else False diff --git a/lti_consumer/plugin/compat.py b/lti_consumer/plugin/compat.py index 588f81991357f5be27389a7959c69021f5e364e7..6c9ed1fc2f56f8c2452e994eaea808a91b56da1d 100644 --- a/lti_consumer/plugin/compat.py +++ b/lti_consumer/plugin/compat.py @@ -14,24 +14,6 @@ from lti_consumer.exceptions import LtiError log = logging.getLogger(__name__) -# Waffle flags configuration - -# Namespace -WAFFLE_NAMESPACE = 'lti_consumer' - -# Course Waffle Flags -# .. toggle_name: lti_consumer.lti_nrps_transmit_pii -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: When enabled, the LTI NRPS endpoint will include learner PII -# in the response (username, email, etc). -# .. toggle_use_cases: open_edx -# .. toggle_creation_date: 2021-06-03 -# .. toggle_tickets: TNL-7974, https://github.com/edx/xblock-lti-consumer/pull/124 -# .. toggle_warnings: None. -LTI_NRPS_TRANSMIT_PII = 'lti_nrps_transmit_pii' - - def run_xblock_handler(*args, **kwargs): """ Import and run `handle_xblock_callback` from LMS @@ -187,15 +169,6 @@ def get_course_members(course_key): raise LtiError('NRPS is not available for {}'.format(course_key)) from ex -def get_lti_pii_course_waffle_flag(): - """ - Returns Course Waffle Flag Override for PII information exposure. - """ - # pylint: disable=import-error,import-outside-toplevel - from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag - return CourseWaffleFlag(WAFFLE_NAMESPACE, LTI_NRPS_TRANSMIT_PII, __name__) - - def request_cached(func) -> Callable[[Callable], Callable]: """ Import the `request_cached` decorator from LMS and apply it if available. diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py index 38bad76abb3899d1edce4b3a01c678d9497a1305..c5901d907fd25d75623aa437d49090648e94c006 100644 --- a/lti_consumer/plugin/views.py +++ b/lti_consumer/plugin/views.py @@ -19,6 +19,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.status import HTTP_403_FORBIDDEN +from lti_consumer.api import get_lti_pii_sharing_state_for_course from lti_consumer.exceptions import LtiError from lti_consumer.models import ( LtiConfiguration, @@ -56,7 +57,7 @@ from lti_consumer.lti_1p3.extensions.rest_framework.parsers import ( ) from lti_consumer.lti_1p3.extensions.rest_framework.utils import IgnoreContentNegotiation from lti_consumer.plugin import compat -from lti_consumer.utils import _, expose_pii_fields +from lti_consumer.utils import _ log = logging.getLogger(__name__) @@ -464,7 +465,7 @@ class LtiNrpsContextMembershipViewSet(viewsets.ReadOnlyModelViewSet): Overrides ModelViewSet's `get_serializer_class` method. Checks if PII fields can be exposed and returns appropiate serializer. """ - if expose_pii_fields(self.request.lti_configuration.location.course_key): + if get_lti_pii_sharing_state_for_course(self.request.lti_configuration.location.course_key): return LtiNrpsContextMembershipPIISerializer else: return LtiNrpsContextMembershipBasicSerializer diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_nrps.py b/lti_consumer/tests/unit/plugin/test_views_lti_nrps.py index 323d5b2e8c1009d5afeec5d8f9ceb5f684307dea..6b13c9117bf621af802cfaf90b0d1cdc347ef5bc 100644 --- a/lti_consumer/tests/unit/plugin/test_views_lti_nrps.py +++ b/lti_consumer/tests/unit/plugin/test_views_lti_nrps.py @@ -1,7 +1,7 @@ """ Tests for LTI Names and Role Provisioning Service views. """ -from mock import patch, PropertyMock +from mock import Mock, patch, PropertyMock from Cryptodome.PublicKey import RSA from jwkest.jwk import RSAKey from rest_framework.test import APITransactionTestCase @@ -226,12 +226,12 @@ class LtiNrpsContextMembershipViewsetTestCase(LtiNrpsTestCase): response = self.client.get(self.context_membership_endpoint) self.assertEqual(response.status_code, 403) - @patch('lti_consumer.plugin.views.expose_pii_fields', return_value=False) + @patch('lti_consumer.plugin.views.get_lti_pii_sharing_state_for_course', Mock(return_value=False)) @patch( 'lti_consumer.plugin.views.compat.get_course_members', - side_effect=patch_get_memberships() + Mock(side_effect=patch_get_memberships()), ) - def test_token_with_correct_scope(self, get_course_members_patcher, expose_pii_fields_patcher): # pylint: disable=unused-argument + def test_token_with_correct_scope(self): """ Test if context membership returns correct response when token has correct scope """ @@ -240,14 +240,14 @@ class LtiNrpsContextMembershipViewsetTestCase(LtiNrpsTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response['content-type'], 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json') - @patch('lti_consumer.plugin.views.expose_pii_fields', return_value=False) + @patch('lti_consumer.plugin.views.get_lti_pii_sharing_state_for_course', return_value=False) @patch( 'lti_consumer.plugin.views.compat.get_course_members', - side_effect=patch_get_memberships({ + Mock(side_effect=patch_get_memberships({ 'student': 4 - }) + })), ) - def test_get_without_pii(self, get_course_members_patcher, expose_pii_fields_patcher): # pylint: disable=unused-argument + def test_get_without_pii(self, expose_pii_fields_patcher): """ Test context membership endpoint response structure with PII not exposed. """ @@ -267,14 +267,14 @@ class LtiNrpsContextMembershipViewsetTestCase(LtiNrpsTestCase): self.assertNotIn('email', member_fields) self.assertNotIn('name', member_fields) - @patch('lti_consumer.plugin.views.expose_pii_fields', return_value=True) + @patch('lti_consumer.plugin.views.get_lti_pii_sharing_state_for_course', return_value=True) @patch( 'lti_consumer.plugin.views.compat.get_course_members', - side_effect=patch_get_memberships({ + Mock(side_effect=patch_get_memberships({ 'student': 4 - }) + })), ) - def test_get_with_pii(self, get_course_members_patcher, expose_pii_fields_patcher): # pylint: disable=unused-argument + def test_get_with_pii(self, expose_pii_fields_patcher): """ Test context membership endpoint response structure with PII exposed. """ @@ -295,14 +295,14 @@ class LtiNrpsContextMembershipViewsetTestCase(LtiNrpsTestCase): self.assertIn('email', member_fields) self.assertIn('name', member_fields) - @patch('lti_consumer.plugin.views.expose_pii_fields', return_value=False) + @patch('lti_consumer.plugin.views.get_lti_pii_sharing_state_for_course', Mock(return_value=False)) @patch( 'lti_consumer.plugin.views.compat.get_course_members', - side_effect=patch_get_memberships({ + Mock(side_effect=patch_get_memberships({ 'exception': True - }) + })), ) - def test_enrollment_limit_gate(self, get_course_members_patcher, expose_pii_fields_patcher): # pylint: disable=unused-argument + def test_enrollment_limit_gate(self): """ Test if number of enrolled user is larger than the limit, api returns 404 response. """ diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py index b6f3b9ac9361ae2c664524ab91adc831c9a540ec..be7a61440dc010f782934216a08cb1652f5c0e65 100644 --- a/lti_consumer/tests/unit/test_models.py +++ b/lti_consumer/tests/unit/test_models.py @@ -17,7 +17,7 @@ from opaque_keys.edx.locator import CourseLocator from lti_consumer.lti_1p1.consumer import LtiConsumer1p1 from lti_consumer.lti_xblock import LtiConsumerXBlock from lti_consumer.models import ( - CourseEditLTIFieldsEnabledFlag, + CourseAllowPIISharingInLTIFlag, LtiAgsLineItem, LtiAgsScore, LtiConfiguration, @@ -371,7 +371,7 @@ def lti_consumer_fields_editing_flag(course_id, enabled_for_course=False): enabled_for_course (bool): whether feature is enabled for 'course_id' """ RequestCache.clear_all_namespaces() - CourseEditLTIFieldsEnabledFlag.objects.create(course_id=course_id, enabled=enabled_for_course) + CourseAllowPIISharingInLTIFlag.objects.create(course_id=course_id, enabled=enabled_for_course) yield @@ -403,7 +403,7 @@ class TestLTIConsumerHideFieldsFlag(TestCase): course_id=self.course_id, enabled_for_course=enabled_for_course ): - feature_enabled = CourseEditLTIFieldsEnabledFlag.lti_access_to_learners_editable( + feature_enabled = CourseAllowPIISharingInLTIFlag.lti_access_to_learners_editable( self.course_id, is_already_sharing_learner_info, ) @@ -418,11 +418,11 @@ class TestLTIConsumerHideFieldsFlag(TestCase): This tests the backward compatibility which currently is: if an existing course run is already sharing learner information then this feature should be enabled for that course run by default. """ - feature_enabled = CourseEditLTIFieldsEnabledFlag.lti_access_to_learners_editable( + feature_enabled = CourseAllowPIISharingInLTIFlag.lti_access_to_learners_editable( self.course_id, is_already_sharing_learner_info, ) - feature_flag_created = CourseEditLTIFieldsEnabledFlag.objects.filter(course_id=self.course_id).exists() + feature_flag_created = CourseAllowPIISharingInLTIFlag.objects.filter(course_id=self.course_id).exists() self.assertEqual(feature_flag_created, is_already_sharing_learner_info) self.assertEqual(feature_enabled, is_already_sharing_learner_info) @@ -434,10 +434,10 @@ class TestLTIConsumerHideFieldsFlag(TestCase): course_id=self.course_id, enabled_for_course=True ): - self.assertTrue(CourseEditLTIFieldsEnabledFlag.lti_access_to_learners_editable(self.course_id, False)) + self.assertTrue(CourseAllowPIISharingInLTIFlag.lti_access_to_learners_editable(self.course_id, False)) with lti_consumer_fields_editing_flag( course_id=self.course_id, enabled_for_course=False ): - self.assertFalse(CourseEditLTIFieldsEnabledFlag.lti_access_to_learners_editable(self.course_id, False)) + self.assertFalse(CourseAllowPIISharingInLTIFlag.lti_access_to_learners_editable(self.course_id, False)) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 2b652b3d72c2d463a53d47b65829f9ca90fc5cf8..e81f177a31e48402c10c85447f80cac59d5a3491 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -2,7 +2,6 @@ Utility functions for LTI Consumer block """ from django.conf import settings -from lti_consumer.plugin.compat import get_lti_pii_course_waffle_flag def _(text): @@ -12,17 +11,6 @@ def _(text): return text -def expose_pii_fields(course_key): - """ - Returns `true` if Use's PII fields can be exposed to LTI endpoints - for given course key. ex - LTI-NRPS Context Membership Endpoint. - - Args: - course_key - """ - return get_lti_pii_course_waffle_flag().is_enabled(course_key) - - def get_lms_base(): """ Returns LMS base url to be used as issuer on OAuth2 flows diff --git a/setup.py b/setup.py index 38a2ad4e0d02f47f192a71442d5ad04793021201..597bb1014c23958b06ddb1e15d52ba35ba5027aa 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ with open('README.rst') as _f: setup( name='lti-consumer-xblock', - version='2.11.0', + version='3.0.0', author='Open edX project', author_email='oscm@edx.org', description='This XBlock implements the consumer side of the LTI specification.',