diff --git a/integrated_channels/degreed2/migrations/0024_auto_20231011_0853.py b/integrated_channels/degreed2/migrations/0024_auto_20231011_0853.py new file mode 100644 index 0000000000..e2932df1d4 --- /dev/null +++ b/integrated_channels/degreed2/migrations/0024_auto_20231011_0853.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.20 on 2023-10-11 08:53 + +from django.db import migrations +from integrated_channels.utils import dummy_reverse +import fernet_fields.fields + + +def populate_decrypted_fields(apps, schema_editor): + """ + Populates the encryption fields with the data previously stored in database. + """ + Degreed2EnterpriseCustomerConfiguration = apps.get_model('degreed2', 'Degreed2EnterpriseCustomerConfiguration') + + for degreed2_enterprise_configuration in Degreed2EnterpriseCustomerConfiguration.objects.all(): + degreed2_enterprise_configuration.decrypted_client_id = degreed2_enterprise_configuration.client_id + degreed2_enterprise_configuration.decrypted_client_secret = degreed2_enterprise_configuration.client_secret + degreed2_enterprise_configuration.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0023_alter_historicaldegreed2enterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='degreed2enterprisecustomerconfiguration', + 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='degreed2enterprisecustomerconfiguration', + 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'), + ), + migrations.AddField( + model_name='historicaldegreed2enterprisecustomerconfiguration', + 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='historicaldegreed2enterprisecustomerconfiguration', + 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'), + ), + migrations.RunPython(populate_decrypted_fields, dummy_reverse), + ] diff --git a/integrated_channels/degreed2/models.py b/integrated_channels/degreed2/models.py index 11336309d7..cabfd82877 100644 --- a/integrated_channels/degreed2/models.py +++ b/integrated_channels/degreed2/models.py @@ -6,9 +6,11 @@ import json from logging import getLogger +from fernet_fields import EncryptedCharField from simple_history.models import HistoricalRecords from django.db import models +from django.utils.encoding import force_bytes, force_str from integrated_channels.degreed2.exporters.content_metadata import Degreed2ContentMetadataExporter from integrated_channels.degreed2.exporters.learner_data import Degreed2LearnerExporter @@ -41,6 +43,39 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat ) ) + 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, @@ -52,6 +87,39 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat ) ) + 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 + degreed_base_url = models.CharField( max_length=255, blank=True, diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index b41c4b55f9..e140194291 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -489,3 +489,12 @@ def get_enterprise_client_by_channel_code(channel_code): 'canvas': CanvasAPIClient, } return _enterprise_client_model_by_channel_code[channel_code] + + +def dummy_reverse(_apps, _schema_editor): + """ + Reverse a data migration but do nothing. + :param _apps: + :param _schema_editor: + :return: + """