Skip to content

Commit

Permalink
Merge branch 'master' into hu/ent-8713
Browse files Browse the repository at this point in the history
  • Loading branch information
brobro10000 authored May 7, 2024
2 parents 931dfef + 83f2cff commit 12b4e9f
Show file tree
Hide file tree
Showing 15 changed files with 195 additions and 43 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ Unreleased
--------
* feat: updates tasks usage of create_recipient to create_recipients

[4.17.3]
--------
* feat: replacing non encrypted fields of blackboard config model with encypted ones

[4.17.2]
--------
* feat: added fields for holding encrypted data in database for blackboard

[4.17.1]
--------
* revert: revert async task functionality implemented in 4.15.6
* fix: update language cookie for an enterprise learner

[4.17.0]
--------
* feat: limit the number of resulting learners in Django admin manage learners view
Expand Down
2 changes: 2 additions & 0 deletions enterprise/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Middleware for enterprise app.
"""

from django.conf import settings
from django.utils.deprecation import MiddlewareMixin

from enterprise.utils import get_enterprise_customer_for_user
Expand Down Expand Up @@ -71,3 +72,4 @@ def process_request(self, request):
if not user_pref and not is_request_from_mobile_app(request):
# pylint: disable=protected-access
request._anonymous_user_cookie_lang = enterprise_customer.default_language
request.COOKIES[settings.LANGUAGE_COOKIE_NAME] = enterprise_customer.default_language
3 changes: 2 additions & 1 deletion enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,8 @@ def root(*args):
'SAP': 1,
}

LANGUAGE_COOKIE = 'openedx-language-preference'
LANGUAGE_COOKIE_NAME = "openedx-language-preference"
SHARED_COOKIE_DOMAIN = ''

ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = f'{LMS_INTERNAL_ROOT_URL}/oauth2'
ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY = 'test_backend_oauth2_key'
Expand Down
8 changes: 4 additions & 4 deletions enterprise/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
from enterprise.api import activate_admin_permissions
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.decorators import disable_for_loaddata
from enterprise.tasks import create_enterprise_enrollment, update_enterprise_learners_user_preference
from enterprise.tasks import create_enterprise_enrollment
from enterprise.utils import (
NotConnectedToOpenEdX,
get_default_catalog_content_filter,
localized_utcnow,
unset_enterprise_learner_language,
unset_language_of_all_enterprise_learners,
)
from integrated_channels.blackboard.models import BlackboardEnterpriseCustomerConfiguration
from integrated_channels.canvas.models import CanvasEnterpriseCustomerConfiguration
Expand Down Expand Up @@ -103,11 +104,10 @@ def update_lang_pref_of_all_learners(sender, instance, **kwargs): # pylint: dis
# The middleware in the enterprise will handle the cases for setting a proper language for the learner.
if instance.default_language:
prev_state = models.EnterpriseCustomer.objects.filter(uuid=instance.uuid).first()
if prev_state and prev_state.default_language != instance.default_language:
if prev_state is None or prev_state.default_language != instance.default_language:
# Unset the language preference of all the learners linked with the enterprise customer.
# The middleware in the enterprise will handle the cases for setting a proper language for the learner.
logger.info('Task triggered to update user preference for learners. Enterprise: [%s]', instance.uuid)
update_enterprise_learners_user_preference.delay(instance.uuid)
unset_language_of_all_enterprise_learners(instance)


@receiver(pre_save, sender=models.EnterpriseCustomerBrandingConfiguration)
Expand Down
13 changes: 0 additions & 13 deletions enterprise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
batch_dict,
get_enterprise_customer,
send_email_notification_message,
unset_language_of_all_enterprise_learners,
)

LOGGER = getLogger(__name__)
Expand Down Expand Up @@ -342,15 +341,3 @@ def send_group_membership_removal_notification(enterprise_customer_uuid, members
)
LOGGER.exception(message)
raise exc


@shared_task
@set_code_owner_attribute
def update_enterprise_learners_user_preference(enterprise_customer_uuid):
"""
Update the user preference `pref-lang` attribute for all enterprise learners linked with an enterprise.
Arguments:
* enterprise_customer_uuid (UUID): uuid of an enterprise customer
"""
unset_language_of_all_enterprise_learners(enterprise_customer_uuid)
9 changes: 4 additions & 5 deletions enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2220,18 +2220,17 @@ def get_platform_logo_url():
return urljoin(settings.LMS_ROOT_URL, logo_url)


def unset_language_of_all_enterprise_learners(enterprise_customer_uuid):
def unset_language_of_all_enterprise_learners(enterprise_customer):
"""
Unset the language preference of all the learners belonging to the given enterprise customer.
Arguments:
enterprise_customer_uuid (UUI): uuid of an enterprise customer
enterprise_customer (UUI): Instance of the enterprise customer.
"""
if UserPreference:
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
user_ids = list(enterprise_customer.enterprise_customer_users.values_list('user_id', flat=True))

LOGGER.info('Update user preference started for learners. Enterprise: [%s]', enterprise_customer_uuid)
LOGGER.info('Update user preference started for learners. Enterprise: [%s]', enterprise_customer.uuid)

for chunk in batch(user_ids, batch_size=10000):
UserPreference.objects.filter(
Expand All @@ -2242,7 +2241,7 @@ def unset_language_of_all_enterprise_learners(enterprise_customer_uuid):
)
LOGGER.info('Updated user preference for learners. Batch Size: [%s]', len(chunk))

LOGGER.info('Update user preference completed for learners. Enterprise: [%s]', enterprise_customer_uuid)
LOGGER.info('Update user preference completed for learners. Enterprise: [%s]', enterprise_customer.uuid)


def unset_enterprise_learner_language(enterprise_customer_user):
Expand Down
5 changes: 5 additions & 0 deletions integrated_channels/api/v1/blackboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ class Meta:
extra_fields = (
'client_id',
'client_secret',
'encrypted_client_id',
'encrypted_client_secret',
'blackboard_base_url',
'refresh_token',
'uuid',
'oauth_authorization_url',
)
fields = EnterpriseCustomerPluginConfigSerializer.Meta.fields + extra_fields

encrypted_client_id = serializers.CharField(required=False, allow_blank=False, read_only=False)
encrypted_client_secret = serializers.CharField(required=False, allow_blank=False, read_only=False)


class BlackboardGlobalConfigSerializer(serializers.ModelSerializer):

Expand Down
15 changes: 13 additions & 2 deletions integrated_channels/blackboard/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import requests

from django.apps import apps
from django.conf import settings
from django.db import transaction

from integrated_channels.blackboard.exporters.content_metadata import BLACKBOARD_COURSE_CONTENT_NAME
Expand Down Expand Up @@ -426,15 +427,25 @@ def _create_auth_header(self):
"""
auth header in oauth2 token format as required by blackboard doc
"""
app_key = self.enterprise_configuration.client_id
use_encrypted_user_data = getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False)
app_key = (
self.enterprise_configuration.decrypted_client_id
if use_encrypted_user_data
else self.enterprise_configuration.client_id
)
if not app_key:
if not self.global_blackboard_config.app_key:
raise ClientError(
"Failed to generate oauth access token: Client ID required.",
HTTPStatus.INTERNAL_SERVER_ERROR.value
)
app_key = self.global_blackboard_config.app_key
app_secret = self.enterprise_configuration.client_secret

app_secret = (
self.enterprise_configuration.decrypted_client_secret
if use_encrypted_user_data
else self.enterprise_configuration.client_secret
)
if not app_secret:
if not self.global_blackboard_config.app_secret:
raise ClientError(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.23 on 2024-04-22 17:09

from django.db import migrations
import fernet_fields.fields


class Migration(migrations.Migration):

dependencies = [
('blackboard', '0019_delete_historicalblackboardenterprisecustomerconfiguration'),
]

operations = [
migrations.AddField(
model_name='blackboardenterprisecustomerconfiguration',
name='decrypted_client_id',
field=fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The API Client ID (encrypted at db level) provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, verbose_name='API Client ID encrypted at db level'),
),
migrations.AddField(
model_name='blackboardenterprisecustomerconfiguration',
name='decrypted_client_secret',
field=fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The API Client Secret (encrypted at db level) provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, verbose_name='API Client Secret encrypted at db level'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated by Django 3.2.23 on 2024-04-23 10:57

from django.db import migrations
from integrated_channels.blackboard.utils import populate_decrypted_fields_blackboard


class Migration(migrations.Migration):

dependencies = [
('blackboard', '0020_auto_20240422_1709'),
]

operations = [
migrations.RunPython(populate_decrypted_fields_blackboard, reverse_code=migrations.RunPython.noop),
]
68 changes: 68 additions & 0 deletions integrated_channels/blackboard/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from logging import getLogger

from config_models.models import ConfigurationModel
from fernet_fields import EncryptedCharField
from six.moves.urllib.parse import urljoin

from django.conf import settings
from django.db import models
from django.utils.encoding import force_bytes, force_str

from integrated_channels.blackboard.exporters.content_metadata import BlackboardContentMetadataExporter
from integrated_channels.blackboard.exporters.learner_data import BlackboardLearnerExporter
Expand Down Expand Up @@ -108,6 +110,39 @@ class BlackboardEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigur
)
)

decrypted_client_id = EncryptedCharField(
max_length=255,
blank=True,
default='',
verbose_name="API Client ID encrypted at db level",
help_text=(
"The API Client ID (encrypted at db level) provided to edX by the enterprise customer to be used"
" to make API calls to Degreed on behalf of the customer."
)
)

@property
def encrypted_client_id(self):
"""
Return encrypted client_id as a string.
The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the
decrypted_client_id field. This method will encrypt the client_id again before sending.
"""
if self.decrypted_client_id:
return force_str(
self._meta.get_field('decrypted_client_id').fernet.encrypt(
force_bytes(self.decrypted_client_id)
)
)
return self.decrypted_client_id

@encrypted_client_id.setter
def encrypted_client_id(self, value):
"""
Set the encrypted client_id.
"""
self.decrypted_client_id = value

client_secret = models.CharField(
max_length=255,
blank=True,
Expand All @@ -119,6 +154,39 @@ class BlackboardEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigur
)
)

decrypted_client_secret = EncryptedCharField(
max_length=255,
blank=True,
default='',
verbose_name="API Client Secret encrypted at db level",
help_text=(
"The API Client Secret (encrypted at db level) provided to edX by the enterprise customer to be "
"used to make API calls to Degreed on behalf of the customer."
),
)

@property
def encrypted_client_secret(self):
"""
Return encrypted client_secret as a string.
The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the
decrypted_client_secret field. This method will encrypt the client_secret again before sending.
"""
if self.decrypted_client_secret:
return force_str(
self._meta.get_field('decrypted_client_secret').fernet.encrypt(
force_bytes(self.decrypted_client_secret)
)
)
return self.decrypted_client_secret

@encrypted_client_secret.setter
def encrypted_client_secret(self, value):
"""
Set the encrypted client_secret.
"""
self.decrypted_client_secret = value

blackboard_base_url = models.CharField(
max_length=255,
blank=True,
Expand Down
17 changes: 17 additions & 0 deletions integrated_channels/blackboard/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
Utilities for Blackboard integrated channels.
"""


def populate_decrypted_fields_blackboard(apps, schema_editor=None): # pylint: disable=unused-argument
"""
Populates the encryption fields in Blackboard config with the data previously stored in database.
"""
BlackboardEnterpriseCustomerConfiguration = apps.get_model(
'blackboard', 'BlackboardEnterpriseCustomerConfiguration'
)

for blackboard_enterprise_configuration in BlackboardEnterpriseCustomerConfiguration.objects.all():
blackboard_enterprise_configuration.decrypted_client_id = blackboard_enterprise_configuration.client_id
blackboard_enterprise_configuration.decrypted_client_secret = blackboard_enterprise_configuration.client_secret
blackboard_enterprise_configuration.save()
2 changes: 2 additions & 0 deletions test_utils/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,8 @@ class Meta:
blackboard_base_url = factory.LazyAttribute(lambda x: FAKER.url())
client_id = factory.LazyAttribute(lambda x: FAKER.random_int(min=1))
client_secret = factory.LazyAttribute(lambda x: FAKER.uuid4())
decrypted_client_id = factory.LazyAttribute(lambda x: FAKER.random_int(min=1))
decrypted_client_secret = factory.LazyAttribute(lambda x: FAKER.uuid4())
refresh_token = factory.LazyAttribute(lambda x: FAKER.uuid4())


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import json
from unittest import mock

from django.apps import apps
from django.urls import reverse

from enterprise.constants import ENTERPRISE_ADMIN_ROLE
from enterprise.utils import localized_utcnow
from integrated_channels.blackboard.models import BlackboardEnterpriseCustomerConfiguration
from integrated_channels.blackboard.utils import populate_decrypted_fields_blackboard
from test_utils import FAKE_UUIDS, APITest, factories


Expand Down Expand Up @@ -129,16 +131,39 @@ def test_update(self, mock_current_request):
payload = {
'client_secret': 1000,
'client_id': 1001,
'encrypted_client_secret': 1000,
'encrypted_client_id': 1001,
'blackboard_base_url': 'http://testing2',
'enterprise_customer': FAKE_UUIDS[0],
}
response = self.client.put(url, payload)
self.enterprise_customer_conf.refresh_from_db()
self.assertEqual(self.enterprise_customer_conf.client_secret, '1000')
self.assertEqual(self.enterprise_customer_conf.client_id, '1001')
self.assertEqual(self.enterprise_customer_conf.decrypted_client_secret, '1000')
self.assertEqual(self.enterprise_customer_conf.decrypted_client_id, '1001')
self.assertEqual(self.enterprise_customer_conf.blackboard_base_url, 'http://testing2')
self.assertEqual(response.status_code, 200)

@mock.patch('enterprise.rules.crum.get_current_request')
def test_populate_decrypted_fields(self, mock_current_request):
mock_current_request.return_value = self.get_request_with_jwt_cookie(
system_wide_role=ENTERPRISE_ADMIN_ROLE,
context=self.enterprise_customer.uuid,
)
url = reverse('api:v1:blackboard:configuration-detail', args=[self.enterprise_customer_conf.id])
client_secret = self.enterprise_customer_conf.client_secret
payload = {
'encrypted_client_secret': '1000',
'enterprise_customer': FAKE_UUIDS[0],
}
self.client.put(url, payload)
self.enterprise_customer_conf.refresh_from_db()
self.assertEqual(self.enterprise_customer_conf.decrypted_client_secret, '1000')
populate_decrypted_fields_blackboard(apps)
self.enterprise_customer_conf.refresh_from_db()
self.assertEqual(self.enterprise_customer_conf.decrypted_client_secret, client_secret)

@mock.patch('enterprise.rules.crum.get_current_request')
def test_partial_update(self, mock_current_request):
mock_current_request.return_value = self.get_request_with_jwt_cookie(
Expand Down
Loading

0 comments on commit 12b4e9f

Please sign in to comment.