mirror of
https://github.com/ansible/awx.git
synced 2026-01-22 15:08:03 -03:30
Add Re-Encryption migrations, helpers, and tests
This commit is contained in:
parent
b5d61c3c53
commit
8d96d08510
16
awx/conf/migrations/0004_v320_reencrypt.py
Normal file
16
awx/conf/migrations/0004_v320_reencrypt.py
Normal file
@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
from awx.conf.migrations import _reencrypt
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conf', '0003_v310_JSONField_changes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(_reencrypt.replace_aesecb_fernet),
|
||||
]
|
||||
110
awx/conf/migrations/_reencrypt.py
Normal file
110
awx/conf/migrations/_reencrypt.py
Normal file
@ -0,0 +1,110 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
import six
|
||||
from django.utils.encoding import smart_str
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from awx.conf.settings import get_settings_to_cache
|
||||
from awx.conf import settings_registry
|
||||
|
||||
|
||||
__all__ = ['replace_aesecb_fernet', 'get_encryption_key', 'encrypt_field',
|
||||
'decrypt_value', 'decrypt_value', 'decrypt_field_value']
|
||||
|
||||
|
||||
def replace_aesecb_fernet(apps, schema_editor):
|
||||
Setting = apps.get_model('conf', 'Setting')
|
||||
settings_to_cache = get_settings_to_cache(settings_registry)
|
||||
|
||||
for setting in Setting.objects.filter(key__in=settings_to_cache.keys(), user__isnull=True).order_by('pk'):
|
||||
if settings_registry.is_setting_encrypted(setting.key):
|
||||
try:
|
||||
setting.value = decrypt_field(setting, 'value')
|
||||
setting.save()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
def get_encryption_key(field_name, pk=None):
|
||||
'''
|
||||
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)
|
||||
if pk is not None:
|
||||
h.update(str(pk))
|
||||
h.update(field_name)
|
||||
return h.digest()[:16]
|
||||
|
||||
|
||||
def decrypt_value(encryption_key, value):
|
||||
raw_data = value[len('$encrypted$'):]
|
||||
# If the encrypted string contains a UTF8 marker, discard it
|
||||
utf8 = raw_data.startswith('UTF8$')
|
||||
if utf8:
|
||||
raw_data = raw_data[len('UTF8$'):]
|
||||
algo, b64data = raw_data.split('$', 1)
|
||||
if algo != 'AES':
|
||||
raise ValueError('unsupported algorithm: %s' % algo)
|
||||
encrypted = base64.b64decode(b64data)
|
||||
cipher = AES.new(encryption_key, AES.MODE_ECB)
|
||||
value = cipher.decrypt(encrypted)
|
||||
value = value.rstrip('\x00')
|
||||
# If the encrypted string contained a UTF8 marker, decode the data
|
||||
if utf8:
|
||||
value = value.decode('utf-8')
|
||||
return value
|
||||
|
||||
|
||||
def decrypt_field(instance, field_name, subfield=None):
|
||||
'''
|
||||
Return content of the given instance and field name decrypted.
|
||||
'''
|
||||
value = getattr(instance, field_name)
|
||||
if isinstance(value, dict) and subfield is not None:
|
||||
value = value[subfield]
|
||||
if not value or not value.startswith('$encrypted$'):
|
||||
return value
|
||||
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(field_name, pk)
|
||||
return decrypt_value(key, value)
|
||||
|
||||
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False):
|
||||
'''
|
||||
Return content of the given instance and field name encrypted.
|
||||
'''
|
||||
value = getattr(instance, field_name)
|
||||
if isinstance(value, dict) and subfield is not None:
|
||||
value = value[subfield]
|
||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||
return value
|
||||
if skip_utf8:
|
||||
utf8 = False
|
||||
else:
|
||||
utf8 = type(value) == six.text_type
|
||||
value = smart_str(value)
|
||||
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'
|
||||
encrypted = cipher.encrypt(value)
|
||||
b64data = base64.b64encode(encrypted)
|
||||
tokens = ['$encrypted', 'AES', b64data]
|
||||
if utf8:
|
||||
# If the value to encrypt is utf-8, we need to add a marker so we
|
||||
# know to decode the data when it's decrypted later
|
||||
tokens.insert(1, 'UTF8')
|
||||
return '$'.join(tokens)
|
||||
25
awx/conf/tests/functional/test_reencrypt_migration.py
Normal file
25
awx/conf/tests/functional/test_reencrypt_migration.py
Normal file
@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
import mock
|
||||
|
||||
from django.apps import apps
|
||||
from awx.conf.migrations._reencrypt import (
|
||||
replace_aesecb_fernet,
|
||||
encrypt_field,
|
||||
decrypt_field,
|
||||
)
|
||||
from awx.conf.settings import Setting
|
||||
from awx.main.utils import decrypt_field as new_decrypt_field
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_settings():
|
||||
with mock.patch('awx.conf.models.encrypt_field', encrypt_field):
|
||||
with mock.patch('awx.conf.settings.decrypt_field', decrypt_field):
|
||||
setting = Setting.objects.create(key='SOCIAL_AUTH_GITHUB_SECRET', value='test')
|
||||
assert setting.value.startswith('$encrypted$AES$')
|
||||
|
||||
replace_aesecb_fernet(apps, None)
|
||||
setting.refresh_from_db()
|
||||
|
||||
assert setting.value.startswith('$encrypted$AESCBC$')
|
||||
assert new_decrypt_field(setting, 'value') == 'test'
|
||||
@ -9,6 +9,7 @@ from psycopg2.extensions import AsIs
|
||||
from django.db import migrations, models
|
||||
|
||||
# AWX
|
||||
from awx.main.migrations import _reencrypt as reencrypt
|
||||
import awx.main.fields
|
||||
from awx.main.models import Host
|
||||
|
||||
@ -260,7 +261,7 @@ class Migration(migrations.Migration):
|
||||
name='Permission',
|
||||
),
|
||||
|
||||
# Insights
|
||||
# Insights
|
||||
migrations.AddField(
|
||||
model_name='host',
|
||||
name='insights_system_id',
|
||||
@ -276,4 +277,5 @@ class Migration(migrations.Migration):
|
||||
name='kind',
|
||||
field=models.CharField(default=b'', help_text='Kind of inventory being represented.', max_length=32, blank=True, choices=[(b'', 'Hosts have a direct link to this inventory.'), (b'smart', 'Hosts for inventory generated using the host_filter property.')]),
|
||||
),
|
||||
migrations.RunPython(reencrypt.replace_aesecb_fernet),
|
||||
]
|
||||
|
||||
16
awx/main/migrations/0044_v320_reencrypt.py
Normal file
16
awx/main/migrations/0044_v320_reencrypt.py
Normal file
@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
from awx.main.migrations import _reencrypt
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0043_v320_instancegroups'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(_reencrypt.replace_aesecb_fernet),
|
||||
]
|
||||
35
awx/main/migrations/_reencrypt.py
Normal file
35
awx/main/migrations/_reencrypt.py
Normal file
@ -0,0 +1,35 @@
|
||||
from awx.conf.migrations._reencrypt import decrypt_field
|
||||
|
||||
|
||||
__all__ = ['replace_aesecb_fernet']
|
||||
|
||||
|
||||
def replace_aesecb_fernet(apps, schema_editor):
|
||||
_notification_templates(apps)
|
||||
_credentials(apps)
|
||||
|
||||
|
||||
def _notification_templates(apps):
|
||||
NotificationTemplate = apps.get_model('main', 'NotificationTemplate')
|
||||
for nt in NotificationTemplate.objects.all():
|
||||
for field in filter(lambda x: nt.notification_class.init_parameters[x]['type'] == "password",
|
||||
nt.notification_class.init_parameters):
|
||||
try:
|
||||
value = decrypt_field(nt, 'notification_configuration', subfield=field)
|
||||
nt.notification_configuration[field] = value
|
||||
except ValueError:
|
||||
continue
|
||||
nt.save()
|
||||
|
||||
|
||||
def _credentials(apps):
|
||||
Credential = apps.get_model('main', 'Credential')
|
||||
for credential in Credential.objects.all():
|
||||
for field_name, value in credential.inputs.items():
|
||||
if field_name in credential.credential_type.secret_fields:
|
||||
try:
|
||||
value = decrypt_field(credential, field_name)
|
||||
credential.inputs[field_name] = value
|
||||
except ValueError:
|
||||
continue
|
||||
credential.save()
|
||||
48
awx/main/tests/functional/test_reencrypt_migration.py
Normal file
48
awx/main/tests/functional/test_reencrypt_migration.py
Normal file
@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
import mock
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from awx.main.models import (
|
||||
NotificationTemplate,
|
||||
Credential,
|
||||
)
|
||||
from awx.main.models.credential import ssh
|
||||
|
||||
from awx.conf.migrations._reencrypt import encrypt_field
|
||||
from awx.main.migrations._reencrypt import (
|
||||
_notification_templates,
|
||||
_credentials,
|
||||
)
|
||||
|
||||
from awx.main.utils import decrypt_field
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_template_migration():
|
||||
with mock.patch('awx.main.models.notifications.encrypt_field', encrypt_field):
|
||||
nt = NotificationTemplate.objects.create(notification_type='slack', notification_configuration=dict(token='test'))
|
||||
|
||||
|
||||
assert nt.notification_configuration['token'].startswith('$encrypted$AES$')
|
||||
|
||||
_notification_templates(apps)
|
||||
nt.refresh_from_db()
|
||||
|
||||
assert nt.notification_configuration['token'].startswith('$encrypted$AESCBC$')
|
||||
assert decrypt_field(nt, 'notification_configuration', subfield='token') == 'test'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_migration():
|
||||
with mock.patch('awx.main.models.credential.encrypt_field', encrypt_field):
|
||||
cred_type = ssh()
|
||||
cred_type.save()
|
||||
|
||||
cred = Credential.objects.create(credential_type=cred_type, inputs=dict(password='test'))
|
||||
|
||||
_credentials(apps)
|
||||
cred.refresh_from_db()
|
||||
|
||||
assert cred.password.startswith('$encrypted$AESCBC$')
|
||||
assert decrypt_field(cred, 'password') == 'test'
|
||||
Loading…
x
Reference in New Issue
Block a user