mirror of
https://github.com/ansible/awx.git
synced 2026-05-12 20:07:37 -02:30
automatically encrypt/decrypt CTinT settings that are stored in memcached
addresses #4246
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
# Python
|
# Python
|
||||||
|
from collections import namedtuple
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -15,7 +16,7 @@ from django.db import ProgrammingError, OperationalError
|
|||||||
from rest_framework.fields import empty, SkipField
|
from rest_framework.fields import empty, SkipField
|
||||||
|
|
||||||
# Tower
|
# Tower
|
||||||
from awx.main.utils import decrypt_field
|
from awx.main.utils import encrypt_field, decrypt_field
|
||||||
from awx.conf import settings_registry
|
from awx.conf import settings_registry
|
||||||
from awx.conf.models import Setting
|
from awx.conf.models import Setting
|
||||||
|
|
||||||
@@ -61,6 +62,75 @@ def _log_database_error():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedCacheProxy(object):
|
||||||
|
|
||||||
|
def __init__(self, cache, registry, encrypter=None, decrypter=None):
|
||||||
|
"""
|
||||||
|
This proxy wraps a Django cache backend and overwrites the
|
||||||
|
`get`/`set`/`set_many` methods to handle field encryption/decryption
|
||||||
|
for sensitive values.
|
||||||
|
|
||||||
|
:param cache: the Django cache backend to proxy to
|
||||||
|
:param registry: the settings registry instance used to determine if
|
||||||
|
a field is encrypted or not.
|
||||||
|
:param encrypter: a callable used to encrypt field values; defaults to
|
||||||
|
``awx.main.utils.encrypt_field``
|
||||||
|
:param decrypter: a callable used to decrypt field values; defaults to
|
||||||
|
``awx.main.utils.decrypt_field``
|
||||||
|
"""
|
||||||
|
|
||||||
|
# These values have to be stored via self.__dict__ in this way to get
|
||||||
|
# around the magic __setattr__ method on this class.
|
||||||
|
self.__dict__['cache'] = cache
|
||||||
|
self.__dict__['registry'] = registry
|
||||||
|
self.__dict__['encrypter'] = encrypter or encrypt_field
|
||||||
|
self.__dict__['decrypter'] = decrypter or decrypt_field
|
||||||
|
|
||||||
|
def get(self, key, **kwargs):
|
||||||
|
value = self.cache.get(key, **kwargs)
|
||||||
|
return self._handle_encryption(self.decrypter, key, value)
|
||||||
|
|
||||||
|
def set(self, key, value, **kwargs):
|
||||||
|
self.cache.set(
|
||||||
|
key,
|
||||||
|
self._handle_encryption(self.encrypter, key, value),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_many(self, data, **kwargs):
|
||||||
|
for key, value in data.items():
|
||||||
|
self.set(key, value, **kwargs)
|
||||||
|
|
||||||
|
def _handle_encryption(self, method, key, value):
|
||||||
|
TransientSetting = namedtuple('TransientSetting', ['pk', 'value'])
|
||||||
|
|
||||||
|
if value is not empty and self.registry.is_setting_encrypted(key):
|
||||||
|
# If the setting exists in the database, we'll use its primary key
|
||||||
|
# as part of the AES key when encrypting/decrypting
|
||||||
|
return method(
|
||||||
|
TransientSetting(
|
||||||
|
pk=getattr(self._get_setting_from_db(key), 'pk', None),
|
||||||
|
value=value
|
||||||
|
),
|
||||||
|
'value'
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the field in question isn't an "encrypted" field, this function is
|
||||||
|
# a no-op; it just returns the provided value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _get_setting_from_db(self, key):
|
||||||
|
field = self.registry.get_setting_field(key)
|
||||||
|
if not field.read_only:
|
||||||
|
return Setting.objects.filter(key=key, user__isnull=True).order_by('pk').first()
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self.cache, name)
|
||||||
|
|
||||||
|
def __setattr__(self, name, value):
|
||||||
|
setattr(self.cache, name, value)
|
||||||
|
|
||||||
|
|
||||||
class SettingsWrapper(UserSettingsHolder):
|
class SettingsWrapper(UserSettingsHolder):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -96,7 +166,7 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
self.__dict__['_awx_conf_preload_expires'] = None
|
self.__dict__['_awx_conf_preload_expires'] = None
|
||||||
self.__dict__['_awx_conf_preload_lock'] = threading.RLock()
|
self.__dict__['_awx_conf_preload_lock'] = threading.RLock()
|
||||||
self.__dict__['_awx_conf_init_readonly'] = False
|
self.__dict__['_awx_conf_init_readonly'] = False
|
||||||
self.__dict__['cache'] = cache
|
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
||||||
self.__dict__['registry'] = registry
|
self.__dict__['registry'] = registry
|
||||||
|
|
||||||
def _get_supported_settings(self):
|
def _get_supported_settings(self):
|
||||||
@@ -138,7 +208,7 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
self.__dict__['_awx_conf_init_readonly'] = True
|
self.__dict__['_awx_conf_init_readonly'] = True
|
||||||
# If local preload timer has expired, check to see if another process
|
# If local preload timer has expired, check to see if another process
|
||||||
# has already preloaded the cache and skip preloading if so.
|
# has already preloaded the cache and skip preloading if so.
|
||||||
if self.cache.get('_awx_conf_preload_expires', empty) is not empty:
|
if self.cache.get('_awx_conf_preload_expires', default=empty) is not empty:
|
||||||
return
|
return
|
||||||
# Initialize all database-configurable settings with a marker value so
|
# Initialize all database-configurable settings with a marker value so
|
||||||
# to indicate from the cache that the setting is not configured without
|
# to indicate from the cache that the setting is not configured without
|
||||||
@@ -167,13 +237,13 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
settings_to_cache = dict([(Setting.get_cache_key(k), v) for k, v in settings_to_cache.items()])
|
settings_to_cache = dict([(Setting.get_cache_key(k), v) for k, v in settings_to_cache.items()])
|
||||||
settings_to_cache['_awx_conf_preload_expires'] = self._awx_conf_preload_expires
|
settings_to_cache['_awx_conf_preload_expires'] = self._awx_conf_preload_expires
|
||||||
logger.debug('cache set_many(%r, %r)', settings_to_cache, SETTING_CACHE_TIMEOUT)
|
logger.debug('cache set_many(%r, %r)', settings_to_cache, SETTING_CACHE_TIMEOUT)
|
||||||
self.cache.set_many(settings_to_cache, SETTING_CACHE_TIMEOUT)
|
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
|
||||||
|
|
||||||
def _get_local(self, name):
|
def _get_local(self, name):
|
||||||
self._preload_cache()
|
self._preload_cache()
|
||||||
cache_key = Setting.get_cache_key(name)
|
cache_key = Setting.get_cache_key(name)
|
||||||
try:
|
try:
|
||||||
cache_value = self.cache.get(cache_key, empty)
|
cache_value = self.cache.get(cache_key, default=empty)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
cache_value = empty
|
cache_value = empty
|
||||||
logger.debug('cache get(%r, %r) -> %r', cache_key, empty, cache_value)
|
logger.debug('cache get(%r, %r) -> %r', cache_key, empty, cache_value)
|
||||||
@@ -211,7 +281,7 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
logger.debug('cache set(%r, %r, %r)', cache_key,
|
logger.debug('cache set(%r, %r, %r)', cache_key,
|
||||||
self._get_cache_value(value),
|
self._get_cache_value(value),
|
||||||
SETTING_CACHE_TIMEOUT)
|
SETTING_CACHE_TIMEOUT)
|
||||||
self.cache.set(cache_key, self._get_cache_value(value), SETTING_CACHE_TIMEOUT)
|
self.cache.set(cache_key, self._get_cache_value(value), timeout=SETTING_CACHE_TIMEOUT)
|
||||||
if value == SETTING_CACHE_NOTSET and not SETTING_CACHE_DEFAULTS:
|
if value == SETTING_CACHE_NOTSET and not SETTING_CACHE_DEFAULTS:
|
||||||
try:
|
try:
|
||||||
value = field.get_default()
|
value = field.get_default()
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ from rest_framework import fields
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.conf import models
|
from awx.conf import models
|
||||||
from awx.conf.settings import SettingsWrapper, SETTING_CACHE_NOTSET
|
from awx.conf.settings import SettingsWrapper, EncryptedCacheProxy, SETTING_CACHE_NOTSET
|
||||||
from awx.conf.registry import SettingsRegistry
|
from awx.conf.registry import SettingsRegistry
|
||||||
|
|
||||||
|
from awx.main.utils import encrypt_field, decrypt_field
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def apply_patches(_patches):
|
def apply_patches(_patches):
|
||||||
@@ -262,3 +264,79 @@ def test_read_only_setting_deletion(settings):
|
|||||||
with pytest.raises(ImproperlyConfigured):
|
with pytest.raises(ImproperlyConfigured):
|
||||||
del settings.AWX_SOME_SETTING
|
del settings.AWX_SOME_SETTING
|
||||||
assert settings.AWX_SOME_SETTING == 'DEFAULT'
|
assert settings.AWX_SOME_SETTING == 'DEFAULT'
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_use_an_encrypted_cache(settings):
|
||||||
|
settings.registry.register(
|
||||||
|
'AWX_ENCRYPTED',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
category=_('System'),
|
||||||
|
category_slug='system',
|
||||||
|
encrypted=True
|
||||||
|
)
|
||||||
|
assert isinstance(settings.cache, EncryptedCacheProxy)
|
||||||
|
assert settings.cache.__dict__['encrypter'] == encrypt_field
|
||||||
|
assert settings.cache.__dict__['decrypter'] == decrypt_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_cache_data_is_encrypted(settings, mocker):
|
||||||
|
"fields marked as `encrypted` are stored in the cache with encryption"
|
||||||
|
settings.registry.register(
|
||||||
|
'AWX_ENCRYPTED',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
category=_('System'),
|
||||||
|
category_slug='system',
|
||||||
|
encrypted=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def rot13(obj, attribute):
|
||||||
|
assert obj.pk == 123
|
||||||
|
return getattr(obj, attribute).encode('rot13')
|
||||||
|
|
||||||
|
native_cache = LocMemCache(str(uuid4()), {})
|
||||||
|
cache = EncryptedCacheProxy(
|
||||||
|
native_cache,
|
||||||
|
settings.registry,
|
||||||
|
encrypter=rot13,
|
||||||
|
decrypter=rot13
|
||||||
|
)
|
||||||
|
# Insert the setting value into the database; the encryption process will
|
||||||
|
# use its primary key as part of the encryption key
|
||||||
|
setting_from_db = mocker.Mock(pk=123, key='AWX_ENCRYPTED', value='SECRET!')
|
||||||
|
mocks = mocker.Mock(**{
|
||||||
|
'order_by.return_value': mocker.Mock(**{
|
||||||
|
'__iter__': lambda self: iter([setting_from_db]),
|
||||||
|
'first.return_value': setting_from_db
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
with mocker.patch('awx.conf.models.Setting.objects.filter', return_value=mocks):
|
||||||
|
cache.set('AWX_ENCRYPTED', 'SECRET!')
|
||||||
|
assert cache.get('AWX_ENCRYPTED') == 'SECRET!'
|
||||||
|
assert native_cache.get('AWX_ENCRYPTED') == 'FRPERG!'
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_sensitive_cache_data_is_encrypted(settings):
|
||||||
|
"readonly fields marked as `encrypted` are stored in the cache with encryption"
|
||||||
|
settings.registry.register(
|
||||||
|
'AWX_ENCRYPTED',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
category=_('System'),
|
||||||
|
category_slug='system',
|
||||||
|
read_only=True,
|
||||||
|
encrypted=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def rot13(obj, attribute):
|
||||||
|
assert obj.pk is None
|
||||||
|
return getattr(obj, attribute).encode('rot13')
|
||||||
|
|
||||||
|
native_cache = LocMemCache(str(uuid4()), {})
|
||||||
|
cache = EncryptedCacheProxy(
|
||||||
|
native_cache,
|
||||||
|
settings.registry,
|
||||||
|
encrypter=rot13,
|
||||||
|
decrypter=rot13
|
||||||
|
)
|
||||||
|
cache.set('AWX_ENCRYPTED', 'SECRET!')
|
||||||
|
assert cache.get('AWX_ENCRYPTED') == 'SECRET!'
|
||||||
|
assert native_cache.get('AWX_ENCRYPTED') == 'FRPERG!'
|
||||||
|
|||||||
@@ -163,22 +163,24 @@ def get_awx_version():
|
|||||||
return __version__
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
def get_encryption_key_for_pk(pk, field_name):
|
def get_encryption_key(field_name, pk=None):
|
||||||
'''
|
'''
|
||||||
Generate key for encrypted password based on instance pk and field name.
|
Generate key for encrypted password based on field name,
|
||||||
|
``settings.SECRET_KEY``, and instance pk (if available).
|
||||||
|
|
||||||
|
:param pk: (optional) the primary key of the ``awx.conf.model.Setting``;
|
||||||
|
can be omitted in situations where you're encrypting a setting
|
||||||
|
that is not database-persistent (like a read-only setting)
|
||||||
'''
|
'''
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
h = hashlib.sha1()
|
h = hashlib.sha1()
|
||||||
h.update(settings.SECRET_KEY)
|
h.update(settings.SECRET_KEY)
|
||||||
h.update(str(pk))
|
if pk is not None:
|
||||||
|
h.update(str(pk))
|
||||||
h.update(field_name)
|
h.update(field_name)
|
||||||
return h.digest()[:16]
|
return h.digest()[:16]
|
||||||
|
|
||||||
|
|
||||||
def get_encryption_key(instance, field_name):
|
|
||||||
return get_encryption_key_for_pk(instance.pk, field_name)
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||||
'''
|
'''
|
||||||
Return content of the given instance and field name encrypted.
|
Return content of the given instance and field name encrypted.
|
||||||
@@ -189,7 +191,7 @@ def encrypt_field(instance, field_name, ask=False, subfield=None):
|
|||||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||||
return value
|
return value
|
||||||
value = smart_str(value)
|
value = smart_str(value)
|
||||||
key = get_encryption_key(instance, field_name)
|
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||||
cipher = AES.new(key, AES.MODE_ECB)
|
cipher = AES.new(key, AES.MODE_ECB)
|
||||||
while len(value) % cipher.block_size != 0:
|
while len(value) % cipher.block_size != 0:
|
||||||
value += '\x00'
|
value += '\x00'
|
||||||
@@ -217,13 +219,13 @@ def decrypt_field(instance, field_name, subfield=None):
|
|||||||
value = value[subfield]
|
value = value[subfield]
|
||||||
if not value or not value.startswith('$encrypted$'):
|
if not value or not value.startswith('$encrypted$'):
|
||||||
return value
|
return value
|
||||||
key = get_encryption_key(instance, field_name)
|
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||||
|
|
||||||
return decrypt_value(key, value)
|
return decrypt_value(key, value)
|
||||||
|
|
||||||
|
|
||||||
def decrypt_field_value(pk, field_name, value):
|
def decrypt_field_value(pk, field_name, value):
|
||||||
key = get_encryption_key_for_pk(pk, field_name)
|
key = get_encryption_key(field_name, pk)
|
||||||
return decrypt_value(key, value)
|
return decrypt_value(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user