mirror of
https://github.com/ansible/awx.git
synced 2026-01-18 13:11:19 -03:30
automatically encrypt/decrypt CTinT settings that are stored in memcached
addresses #4246
This commit is contained in:
parent
eada4ce83f
commit
557bc8006d
@ -1,4 +1,5 @@
|
||||
# Python
|
||||
from collections import namedtuple
|
||||
import contextlib
|
||||
import logging
|
||||
import sys
|
||||
@ -15,7 +16,7 @@ from django.db import ProgrammingError, OperationalError
|
||||
from rest_framework.fields import empty, SkipField
|
||||
|
||||
# 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.models import Setting
|
||||
|
||||
@ -61,6 +62,75 @@ def _log_database_error():
|
||||
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):
|
||||
|
||||
@classmethod
|
||||
@ -96,7 +166,7 @@ class SettingsWrapper(UserSettingsHolder):
|
||||
self.__dict__['_awx_conf_preload_expires'] = None
|
||||
self.__dict__['_awx_conf_preload_lock'] = threading.RLock()
|
||||
self.__dict__['_awx_conf_init_readonly'] = False
|
||||
self.__dict__['cache'] = cache
|
||||
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
||||
self.__dict__['registry'] = registry
|
||||
|
||||
def _get_supported_settings(self):
|
||||
@ -138,7 +208,7 @@ class SettingsWrapper(UserSettingsHolder):
|
||||
self.__dict__['_awx_conf_init_readonly'] = True
|
||||
# If local preload timer has expired, check to see if another process
|
||||
# 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
|
||||
# Initialize all database-configurable settings with a marker value so
|
||||
# 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['_awx_conf_preload_expires'] = self._awx_conf_preload_expires
|
||||
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):
|
||||
self._preload_cache()
|
||||
cache_key = Setting.get_cache_key(name)
|
||||
try:
|
||||
cache_value = self.cache.get(cache_key, empty)
|
||||
cache_value = self.cache.get(cache_key, default=empty)
|
||||
except ValueError:
|
||||
cache_value = empty
|
||||
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,
|
||||
self._get_cache_value(value),
|
||||
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:
|
||||
try:
|
||||
value = field.get_default()
|
||||
|
||||
@ -13,9 +13,11 @@ from rest_framework import fields
|
||||
import pytest
|
||||
|
||||
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.main.utils import encrypt_field, decrypt_field
|
||||
|
||||
|
||||
@contextmanager
|
||||
def apply_patches(_patches):
|
||||
@ -262,3 +264,79 @@ def test_read_only_setting_deletion(settings):
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
del settings.AWX_SOME_SETTING
|
||||
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__
|
||||
|
||||
|
||||
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
|
||||
h = hashlib.sha1()
|
||||
h.update(settings.SECRET_KEY)
|
||||
h.update(str(pk))
|
||||
if pk is not None:
|
||||
h.update(str(pk))
|
||||
h.update(field_name)
|
||||
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):
|
||||
'''
|
||||
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'):
|
||||
return 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)
|
||||
while len(value) % cipher.block_size != 0:
|
||||
value += '\x00'
|
||||
@ -217,13 +219,13 @@ def decrypt_field(instance, field_name, subfield=None):
|
||||
value = value[subfield]
|
||||
if not value or not value.startswith('$encrypted$'):
|
||||
return value
|
||||
key = get_encryption_key(instance, field_name)
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
|
||||
return decrypt_value(key, 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)
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user