diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7365eaab38..acd8302423 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,11 @@ Unreleased ---------- * nothing unreleased +[4.25.4] +---------- +* feat: replaced references from unencrypted to encrypted columns. +* feat: added data migration to populate encrypted columns. + [4.25.3] ---------- * feat: added encrypted client secret for SAP config diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 82d621b507..05a40dcd8a 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.25.3" +__version__ = "4.25.4" diff --git a/enterprise/signals.py b/enterprise/signals.py index 8813b95a44..d16c4ea4de 100644 --- a/enterprise/signals.py +++ b/enterprise/signals.py @@ -464,14 +464,3 @@ def generate_default_orchestration_record_display_name(sender, instance, **kwarg if COURSE_ENROLLMENT_CHANGED is not None: COURSE_ENROLLMENT_CHANGED.connect(course_enrollment_changed_receiver) - - -@receiver(pre_save, sender=SAPSuccessFactorsEnterpriseCustomerConfiguration) -def update_decrypted_credentials(sender, instance, **kwargs): # pylint: disable=unused-argument - """ - Ensure that the decrypted credentials have same values as unencrypted credentials. - """ - if instance.key != instance.decrypted_key: - instance.decrypted_key = instance.key - if instance.secret != instance.decrypted_secret: - instance.decrypted_secret = instance.secret diff --git a/integrated_channels/api/v1/sap_success_factors/serializers.py b/integrated_channels/api/v1/sap_success_factors/serializers.py index d53dc9bca6..370c127e46 100644 --- a/integrated_channels/api/v1/sap_success_factors/serializers.py +++ b/integrated_channels/api/v1/sap_success_factors/serializers.py @@ -1,6 +1,8 @@ """ Serializer for Success Factors configuration. """ +from rest_framework import serializers + from integrated_channels.api.serializers import EnterpriseCustomerPluginConfigSerializer from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration @@ -9,11 +11,11 @@ class SAPSuccessFactorsConfigSerializer(EnterpriseCustomerPluginConfigSerializer class Meta: model = SAPSuccessFactorsEnterpriseCustomerConfiguration extra_fields = ( - 'key', + 'encrypted_key', 'sapsf_base_url', 'sapsf_company_id', 'sapsf_user_id', - 'secret', + 'encrypted_secret', 'user_type', 'additional_locales', 'show_course_price', @@ -21,3 +23,6 @@ class Meta: 'prevent_self_submit_grades', ) fields = EnterpriseCustomerPluginConfigSerializer.Meta.fields + extra_fields + + encrypted_key = serializers.CharField(required=False, allow_blank=False, read_only=False) + encrypted_secret = serializers.CharField(required=False, allow_blank=False, read_only=False) diff --git a/integrated_channels/sap_success_factors/admin/__init__.py b/integrated_channels/sap_success_factors/admin/__init__.py index 7b5d03b440..81e84f90b4 100644 --- a/integrated_channels/sap_success_factors/admin/__init__.py +++ b/integrated_channels/sap_success_factors/admin/__init__.py @@ -48,9 +48,7 @@ class SAPSuccessFactorsEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, "active", "sapsf_base_url", "sapsf_company_id", - "key", "decrypted_key", - "secret", "decrypted_secret", "sapsf_user_id", "user_type", @@ -111,8 +109,8 @@ def has_access_token(self, obj): try: access_token, expires_at = SAPSuccessFactorsAPIClient.get_oauth_access_token( obj.sapsf_base_url, - obj.key, - obj.secret, + obj.decrypted_key, + obj.decrypted_secret, obj.sapsf_company_id, obj.sapsf_user_id, obj.user_type, diff --git a/integrated_channels/sap_success_factors/client.py b/integrated_channels/sap_success_factors/client.py index ec6f1988b1..4219b7a188 100644 --- a/integrated_channels/sap_success_factors/client.py +++ b/integrated_channels/sap_success_factors/client.py @@ -137,8 +137,8 @@ def _create_session(self): self.session.close() oauth_access_token, expires_at = self.get_oauth_access_token( - self.enterprise_configuration.key, - self.enterprise_configuration.secret, + self.enterprise_configuration.decrypted_key, + self.enterprise_configuration.decrypted_secret, self.enterprise_configuration.sapsf_company_id, self.enterprise_configuration.sapsf_user_id, self.enterprise_configuration.user_type, @@ -301,8 +301,8 @@ def _call_post_with_user_override(self, sap_user_id, url, payload): 'SAPSuccessFactorsEnterpriseCustomerConfiguration' ) oauth_access_token, _ = self.get_oauth_access_token( - self.enterprise_configuration.key, - self.enterprise_configuration.secret, + self.enterprise_configuration.decrypted_key, + self.enterprise_configuration.decrypted_secret, self.enterprise_configuration.sapsf_company_id, sap_user_id, SAPSuccessFactorsEnterpriseCustomerConfiguration.USER_TYPE_USER, diff --git a/integrated_channels/sap_success_factors/migrations/0022_auto_20240906_1349.py b/integrated_channels/sap_success_factors/migrations/0022_auto_20240906_1349.py new file mode 100644 index 0000000000..7affe9853c --- /dev/null +++ b/integrated_channels/sap_success_factors/migrations/0022_auto_20240906_1349.py @@ -0,0 +1,15 @@ +# Generated by Django 3.2.23 on 2024-08-26 09:54 + +from django.db import migrations +from integrated_channels.sap_success_factors.utils import populate_decrypted_fields_sap_success_factors + + +class Migration(migrations.Migration): + + dependencies = [ + ('sap_success_factors', '0021_sapsuccessfactorsenterprisecustomerconfiguration_decrypted_secret'), + ] + + operations = [ + migrations.RunPython(populate_decrypted_fields_sap_success_factors, reverse_code=migrations.RunPython.noop), + ] diff --git a/integrated_channels/sap_success_factors/models.py b/integrated_channels/sap_success_factors/models.py index 97b8841a5a..aaa8ff3658 100644 --- a/integrated_channels/sap_success_factors/models.py +++ b/integrated_channels/sap_success_factors/models.py @@ -9,6 +9,7 @@ from fernet_fields import EncryptedCharField from django.db import models +from django.utils.encoding import force_bytes, force_str from django.utils.translation import gettext_lazy as _ from integrated_channels.exceptions import ClientError @@ -73,7 +74,6 @@ class SAPSuccessFactorsEnterpriseCustomerConfiguration(EnterpriseCustomerPluginC (USER_TYPE_USER, 'User'), (USER_TYPE_ADMIN, 'Admin'), ) - key = models.CharField( max_length=255, blank=True, @@ -94,6 +94,28 @@ class SAPSuccessFactorsEnterpriseCustomerConfiguration(EnterpriseCustomerPluginC null=True ) + @property + def encrypted_key(self): + """ + Return encrypted key as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_key field. This method will encrypt the key again before sending. + """ + if self.decrypted_key: + return force_str( + self._meta.get_field('decrypted_key').fernet.encrypt( + force_bytes(self.decrypted_key) + ) + ) + return self.decrypted_key + + @encrypted_key.setter + def encrypted_key(self, value): + """ + Set the encrypted key. + """ + self.decrypted_key = value + sapsf_base_url = models.CharField( max_length=255, blank=True, @@ -135,6 +157,28 @@ class SAPSuccessFactorsEnterpriseCustomerConfiguration(EnterpriseCustomerPluginC null=True ) + @property + def encrypted_secret(self): + """ + Return encrypted secret as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_secret field. This method will encrypt the secret again before sending. + """ + if self.decrypted_secret: + return force_str( + self._meta.get_field('decrypted_secret').fernet.encrypt( + force_bytes(self.decrypted_secret) + ) + ) + return self.decrypted_secret + + @encrypted_secret.setter + def encrypted_secret(self, value): + """ + Set the encrypted secret. + """ + self.decrypted_secret = value + user_type = models.CharField( max_length=20, choices=USER_TYPE_CHOICES, @@ -205,7 +249,7 @@ def is_valid(self): """ missing_items = {'missing': []} incorrect_items = {'incorrect': []} - if not self.key: + if not self.decrypted_key: missing_items.get('missing').append('key') if not self.sapsf_base_url: missing_items.get('missing').append('sapsf_base_url') @@ -213,7 +257,7 @@ def is_valid(self): missing_items.get('missing').append('sapsf_company_id') if not self.sapsf_user_id: missing_items.get('missing').append('sapsf_user_id') - if not self.secret: + if not self.decrypted_secret: missing_items.get('missing').append('secret') if not is_valid_url(self.sapsf_base_url): incorrect_items.get('incorrect').append('sapsf_base_url') diff --git a/integrated_channels/sap_success_factors/utils.py b/integrated_channels/sap_success_factors/utils.py new file mode 100644 index 0000000000..424ec39676 --- /dev/null +++ b/integrated_channels/sap_success_factors/utils.py @@ -0,0 +1,19 @@ +""" +Utilities for SAP Success Factors integrated channel. +""" + + +def populate_decrypted_fields_sap_success_factors(apps, schema_editor=None): # pylint: disable=unused-argument + """ + Populates the encryption fields in SAP Success Factors config with the data previously stored in database. + """ + SAPSuccessFactorsEnterpriseCustomerConfiguration = apps.get_model( + 'sap_success_factors', 'SAPSuccessFactorsEnterpriseCustomerConfiguration' + ) + + for sap_success_factors_enterprise_configuration in SAPSuccessFactorsEnterpriseCustomerConfiguration.objects.all(): + sap_success_factors_enterprise_configuration.decrypted_key = getattr( + sap_success_factors_enterprise_configuration, 'key', '') + sap_success_factors_enterprise_configuration.decrypted_secret = getattr( + sap_success_factors_enterprise_configuration, 'secret', '') + sap_success_factors_enterprise_configuration.save() diff --git a/tests/test_integrated_channels/test_api/test_sap_success_factors/test_views.py b/tests/test_integrated_channels/test_api/test_sap_success_factors/test_views.py index 9270eb5b30..35a6fdc8f8 100644 --- a/tests/test_integrated_channels/test_api/test_sap_success_factors/test_views.py +++ b/tests/test_integrated_channels/test_api/test_sap_success_factors/test_views.py @@ -5,11 +5,13 @@ from unittest import mock from uuid import uuid4 +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.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration +from integrated_channels.sap_success_factors.utils import populate_decrypted_fields_sap_success_factors from test_utils import APITest, factories ENTERPRISE_ID = str(uuid4()) @@ -70,10 +72,6 @@ def test_get(self, mock_current_request): self.sap_config.sapsf_company_id) self.assertEqual(int(data.get('sapsf_user_id')), self.sap_config.sapsf_user_id) - self.assertEqual(data.get('key'), - self.sap_config.key) - self.assertEqual(data.get('secret'), - self.sap_config.secret) self.assertEqual(data.get('user_type'), self.sap_config.user_type) self.assertEqual(data.get('enterprise_customer'), @@ -91,20 +89,43 @@ def test_update(self, mock_current_request): 'sapsf_company_id': 'test', 'enterprise_customer': ENTERPRISE_ID, 'sapsf_user_id': 893489, - 'key': 'testing', - 'secret': 'secret', + 'encrypted_key': '', + 'encrypted_secret': '', 'user_type': 'user', } response = self.client.put(url, payload) self.sap_config.refresh_from_db() self.assertEqual(self.sap_config.sapsf_base_url, 'http://testing2') self.assertEqual(self.sap_config.sapsf_company_id, 'test') - self.assertEqual(self.sap_config.key, 'testing') - self.assertEqual(self.sap_config.secret, 'secret') + self.assertEqual(self.sap_config.decrypted_key, '') + self.assertEqual(self.sap_config.decrypted_secret, '') self.assertEqual(self.sap_config.user_type, 'user') self.assertEqual(self.sap_config.sapsf_user_id, '893489') 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:sap_success_factors:configuration-detail', args=[self.sap_config.id]) + client_secret = getattr(self.sap_config, 'secret', '') + payload = { + 'sapsf_base_url': 'http://testing2', + 'sapsf_company_id': 'test', + 'enterprise_customer': ENTERPRISE_ID, + 'sapsf_user_id': 893489, + 'user_type': 'user', + 'encrypted_secret': '1000', + } + self.client.put(url, payload) + self.sap_config.refresh_from_db() + self.assertEqual(self.sap_config.decrypted_secret, '1000') + populate_decrypted_fields_sap_success_factors(apps) + self.sap_config.refresh_from_db() + self.assertEqual(self.sap_config.encrypted_secret, client_secret) + @mock.patch('enterprise.rules.crum.get_current_request') def test_patch(self, mock_current_request): mock_current_request.return_value = self.get_request_with_jwt_cookie( @@ -164,8 +185,8 @@ def test_is_valid_field(self, mock_current_request): missing, _ = data[0].get('is_valid') assert missing.get('missing') == ['key', 'sapsf_base_url', 'sapsf_company_id', 'sapsf_user_id', 'secret'] - self.sap_config.key = 'ayy' - self.sap_config.secret = 'lmao' + self.sap_config.decrypted_key = 'ayy' + self.sap_config.decrypted_secret = 'lmao' self.sap_config.sapsf_company_id = '1' self.sap_config.sapsf_user_id = '1' self.sap_config.sapsf_base_url = 'http://happy.com' diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py index 2d24bc922b..c3639c908a 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py @@ -298,8 +298,8 @@ def test_learner_data_instructor_paced_no_certificate_null_sso_id( self.config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, sapsf_base_url='enterprise.successfactors.com', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) self.exporter = self.config.get_learner_data_exporter('dummy-user') diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py index 2db7d6d25d..70ebebfaa8 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py @@ -31,11 +31,11 @@ def setUp(self): # so it's okay for it to be any arbitrary channel. We randomly choose SAPSF. self.enterprise_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, - key="client_id", + decrypted_key="client_id", sapsf_base_url="http://test.successfactors.com/", sapsf_company_id="company_id", sapsf_user_id="user_id", - secret="client_secret", + decrypted_secret="client_secret", transmission_chunk_size=5, ) self.enterprise_catalog = factories.EnterpriseCustomerCatalogFactory( diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py index 0d2b7345a2..406ddcd86c 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py @@ -31,11 +31,11 @@ def setUp(self): # so it's okay for it to be any arbitrary channel. We randomly choose SAPSF. self.enterprise_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=enterprise_customer, - key="client_id", + decrypted_key="client_id", sapsf_base_url="http://test.successfactors.com/", sapsf_company_id="company_id", sapsf_user_id="user_id", - secret="client_secret", + decrypted_secret="client_secret", ) self.learner_transmitter = LearnerTransmitter(self.enterprise_config) diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_client.py b/tests/test_integrated_channels/test_sap_success_factors/test_client.py index 4c4ff70da6..86526b7536 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_client.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_client.py @@ -70,11 +70,11 @@ def setUp(self): self.expected_token_response_body = {"expires_in": self.expires_in, "access_token": self.access_token} self.enterprise_config = SAPSuccessFactorsEnterpriseCustomerConfiguration( - key=self.client_id, + encrypted_key=self.client_id, sapsf_base_url=self.url_base, sapsf_company_id=self.company_id, sapsf_user_id=self.user_id, - secret=self.client_secret + encrypted_secret=self.client_secret ) self.enterprise_config.enterprise_customer = EnterpriseCustomerFactory() self.completion_payload = { diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py index 09f17d0be8..ae1d58ec2b 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py @@ -42,11 +42,11 @@ def setUp(self): ) self.enterprise_config = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, - key="client_id", + decrypted_key="client_id", sapsf_base_url="http://test.successfactors.com/", sapsf_company_id="company_id", sapsf_user_id="user_id", - secret="client_secret" + decrypted_secret="client_secret" ) def test_unique_enrollment_id_course_id_constraint(self): diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py index 9c5260545b..12179cd657 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py @@ -41,11 +41,11 @@ def setUp(self): ) self.enterprise_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=enterprise_customer, - key='client_id', + decrypted_key='client_id', sapsf_base_url=self.url_base, sapsf_company_id='company_id', sapsf_user_id='user_id', - secret='client_secret', + decrypted_secret='client_secret', ) factories.SAPSuccessFactorsGlobalConfiguration.objects.create( completion_status_api_path=self.completion_status_api_path, diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_learner_data.py index a36d10d72d..06e1f8f19e 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_learner_data.py @@ -35,11 +35,11 @@ def setUp(self): ) self.enterprise_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, - key="client_id", + decrypted_key="client_id", sapsf_base_url="http://test.successfactors.com/", sapsf_company_id="company_id", sapsf_user_id="user_id", - secret="client_secret" + decrypted_secret="client_secret" ) self.payloads = [ SapSuccessFactorsLearnerDataTransmissionAudit( diff --git a/tests/test_management.py b/tests/test_management.py index 1064e89189..e8862ef224 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -163,8 +163,8 @@ def setUp(self): self.sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) self.sapsf_global_configuration = factories.SAPSuccessFactorsGlobalConfigurationFactory() @@ -255,8 +255,8 @@ def test_transmit_content_metadata_task_with_error( dummy_sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=dummy_enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) @@ -436,8 +436,8 @@ def setUp(self): self.sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) self.sapsf_global_configuration = factories.SAPSuccessFactorsGlobalConfigurationFactory() @@ -1136,8 +1136,8 @@ def setUp(self): self.sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) self.sapsf_global_configuration = factories.SAPSuccessFactorsGlobalConfigurationFactory( @@ -1972,8 +1972,8 @@ def setUp(self): self.customer_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', active=True, ) self.orphaned_content = factories.ContentMetadataItemTransmissionFactory( diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 790589d42e..6cd38d212a 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -1853,8 +1853,8 @@ def setUp(self): self.enterprise_configuration = SAPSuccessFactorsEnterpriseCustomerConfiguration( enterprise_customer=self.customer, sapsf_base_url='enterprise.successfactors.com', - key='key', - secret='secret', + decrypted_key='key', + decrypted_secret='secret', ) @ddt.data(