diff --git a/awx/conf/conf.py b/awx/conf/conf.py index 840bcaeb58..8ba0d072b0 100644 --- a/awx/conf/conf.py +++ b/awx/conf/conf.py @@ -81,6 +81,9 @@ register( # Optional; licensed feature required to be able to view or modify this # setting. feature_required='rebranding', + # Optional; field is stored encrypted in the database and only $encrypted$ + # is returned via the API. + encrypted=True, ) register( diff --git a/awx/conf/models.py b/awx/conf/models.py index 2a32922cbd..bc6cfb3dfc 100644 --- a/awx/conf/models.py +++ b/awx/conf/models.py @@ -10,6 +10,8 @@ from django.db import models # Tower from awx.main.models.base import CreatedModifiedModel from awx.main.fields import JSONField +from awx.main.utils import encrypt_field +from awx.conf import settings_registry __all__ = ['Setting'] @@ -42,6 +44,30 @@ class Setting(CreatedModifiedModel): else: return u'{} = {}'.format(self.key, json_value) + def save(self, *args, **kwargs): + encrypted = settings_registry.is_setting_encrypted(self.key) + new_instance = not bool(self.pk) + # If update_fields has been specified, add our field names to it, + # if it hasn't been specified, then we're just doing a normal save. + update_fields = kwargs.get('update_fields', []) + # When first saving to the database, don't store any encrypted field + # value, but instead save it until after the instance is created. + # Otherwise, store encrypted value to the database. + if encrypted: + if new_instance: + self._saved_value = self.value + self.value = '' + else: + self.value = encrypt_field(self, 'value') + if 'value' not in update_fields: + update_fields.append('value') + super(Setting, self).save(*args, **kwargs) + # After saving a new instance for the first time, set the encrypted + # field and save again. + if encrypted and new_instance: + self.value = self._saved_value + self.save(update_fields=['value']) + @classmethod def get_cache_key(self, key): return key diff --git a/awx/conf/registry.py b/awx/conf/registry.py index 8f178a0967..2534e238c0 100644 --- a/awx/conf/registry.py +++ b/awx/conf/registry.py @@ -90,6 +90,9 @@ class SettingsRegistry(object): setting_names.append(setting) return setting_names + def is_setting_encrypted(self, setting): + return bool(self._registry.get(setting, {}).get('encrypted', False)) + def get_setting_field(self, setting, mixin_class=None, for_user=False, **kwargs): from django.conf import settings from rest_framework.fields import empty @@ -104,6 +107,7 @@ class SettingsRegistry(object): depends_on = frozenset(field_kwargs.pop('depends_on', None) or []) placeholder = field_kwargs.pop('placeholder', empty) feature_required = field_kwargs.pop('feature_required', empty) + encrypted = bool(field_kwargs.pop('encrypted', False)) if getattr(field_kwargs.get('child', None), 'source', None) is not None: field_kwargs['child'].source = None field_instance = field_class(**field_kwargs) @@ -114,6 +118,7 @@ class SettingsRegistry(object): field_instance.placeholder = placeholder if feature_required is not empty: field_instance.feature_required = feature_required + field_instance.encrypted = encrypted original_field_instance = field_instance if field_class != original_field_class: original_field_instance = original_field_class(**field_kwargs) diff --git a/awx/conf/serializers.py b/awx/conf/serializers.py index 6cbc4ae980..744a4770d6 100644 --- a/awx/conf/serializers.py +++ b/awx/conf/serializers.py @@ -45,6 +45,8 @@ class SettingFieldMixin(object): """Mixin to use a registered setting field class for API display/validation.""" def to_representation(self, obj): + if getattr(self, 'encrypted', False) and isinstance(obj, basestring) and obj: + return '$encrypted$' return obj def to_internal_value(self, value): diff --git a/awx/conf/settings.py b/awx/conf/settings.py index 55133a33c3..c08b161237 100644 --- a/awx/conf/settings.py +++ b/awx/conf/settings.py @@ -15,6 +15,7 @@ from django.db import ProgrammingError, OperationalError from rest_framework.fields import empty, SkipField # Tower +from awx.main.utils import decrypt_field from awx.conf import settings_registry from awx.conf.models import Setting @@ -121,7 +122,11 @@ class SettingsWrapper(UserSettingsHolder): for setting in Setting.objects.filter(key__in=settings_to_cache.keys(), user__isnull=True).order_by('pk'): if settings_to_cache[setting.key] != SETTING_CACHE_NOTSET: continue - settings_to_cache[setting.key] = self._get_cache_value(setting.value) + if settings_registry.is_setting_encrypted(setting.key): + value = decrypt_field(setting, 'value') + else: + value = setting.value + settings_to_cache[setting.key] = self._get_cache_value(value) # Load field default value for any settings not found in the database. if SETTING_CACHE_DEFAULTS: for key, value in settings_to_cache.items(): @@ -159,7 +164,10 @@ class SettingsWrapper(UserSettingsHolder): if not field.read_only: setting = Setting.objects.filter(key=name, user__isnull=True).order_by('pk').first() if setting: - value = setting.value + if getattr(field, 'encrypted', False): + value = decrypt_field(setting, 'value') + else: + value = setting.value else: value = SETTING_CACHE_NOTSET if SETTING_CACHE_DEFAULTS: diff --git a/awx/conf/views.py b/awx/conf/views.py index 8f26a2f1cf..99a3daab99 100644 --- a/awx/conf/views.py +++ b/awx/conf/views.py @@ -103,6 +103,8 @@ class SettingSingletonDetail(RetrieveUpdateDestroyAPIView): for key, value in serializer.validated_data.items(): if key == 'LICENSE': continue + if settings_registry.is_setting_encrypted(key) and isinstance(value, basestring) and value.startswith('$encrypted$'): + continue setattr(serializer.instance, key, value) setting = settings_qs.filter(key=key).order_by('pk').first() if not setting: diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index d06d189b6e..94aa316aea 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -65,6 +65,40 @@ def test_ldap_settings(get, put, patch, delete, admin, enterprise_license): patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com, ldap://ldap2.example.com'}, expect=200) +@pytest.mark.django_db +def test_radius_settings(get, put, patch, delete, admin, enterprise_license, settings): + url = reverse('api:setting_singleton_detail', args=('radius',)) + get(url, user=admin, expect=404) + Setting.objects.create(key='LICENSE', value=enterprise_license) + response = get(url, user=admin, expect=200) + put(url, user=admin, data=response.data, expect=200) + # Set secret via the API. + patch(url, user=admin, data={'RADIUS_SECRET': 'mysecret'}, expect=200) + response = get(url, user=admin, expect=200) + assert response.data['RADIUS_SECRET'] == '$encrypted$' + assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') + assert settings.RADIUS_SECRET == 'mysecret' + # Set secret via settings wrapper. + settings_wrapper = settings._awx_conf_settings + settings_wrapper.RADIUS_SECRET = 'mysecret2' + response = get(url, user=admin, expect=200) + assert response.data['RADIUS_SECRET'] == '$encrypted$' + assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') + assert settings.RADIUS_SECRET == 'mysecret2' + # If we send back $encrypted$, the setting is not updated. + patch(url, user=admin, data={'RADIUS_SECRET': '$encrypted$'}, expect=200) + response = get(url, user=admin, expect=200) + assert response.data['RADIUS_SECRET'] == '$encrypted$' + assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') + assert settings.RADIUS_SECRET == 'mysecret2' + # If we send an empty string, the setting is also set to an empty string. + patch(url, user=admin, data={'RADIUS_SECRET': ''}, expect=200) + response = get(url, user=admin, expect=200) + assert response.data['RADIUS_SECRET'] == '' + assert Setting.objects.filter(key='RADIUS_SECRET').first().value == '' + assert settings.RADIUS_SECRET == '' + + @pytest.mark.django_db def test_ui_settings(get, put, patch, delete, admin, enterprise_license): url = reverse('api:setting_singleton_detail', args=('ui',)) diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 0a6642d953..1243fd956b 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -211,6 +211,7 @@ register( category=_('LDAP'), category_slug='ldap', feature_required='ldap', + encrypted=True, ) register( @@ -511,6 +512,7 @@ register( category=_('RADIUS'), category_slug='radius', feature_required='enterprise_auth', + encrypted=True, ) ############################################################################### @@ -552,6 +554,7 @@ register( category=_('Google OAuth2'), category_slug='google-oauth2', placeholder='q2fMVCmEregbg-drvebPp8OW', + encrypted=True, ) register( @@ -641,6 +644,7 @@ register( help_text=_('The OAuth2 secret (Client Secret) from your GitHub developer application.'), category=_('GitHub OAuth2'), category_slug='github', + encrypted=True, ) register( @@ -704,6 +708,7 @@ register( help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'), category=_('GitHub Organization OAuth2'), category_slug='github-org', + encrypted=True, ) register( @@ -778,6 +783,7 @@ register( help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'), category=_('GitHub Team OAuth2'), category_slug='github-team', + encrypted=True, ) register( @@ -852,6 +858,7 @@ register( help_text=_('The OAuth2 secret (Client Secret) from your Azure AD application.'), category=_('Azure AD OAuth2'), category_slug='azuread-oauth2', + encrypted=True, ) register( @@ -955,6 +962,7 @@ register( category=_('SAML'), category_slug='saml', feature_required='enterprise_auth', + encrypted=True, ) register(