mirror of
https://github.com/ansible/awx.git
synced 2026-02-25 06:56:00 -03:30
add an awx-manage command for re-generating SECRET_KEY
This commit is contained in:
129
awx/main/management/commands/regenerate_secret_key.py
Normal file
129
awx/main/management/commands/regenerate_secret_key.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
|
||||||
|
from awx.conf import settings_registry
|
||||||
|
from awx.conf.models import Setting
|
||||||
|
from awx.conf.signals import on_post_save_setting
|
||||||
|
from awx.main.models import (
|
||||||
|
UnifiedJob, Credential, NotificationTemplate, Job, JobTemplate, WorkflowJob,
|
||||||
|
WorkflowJobTemplate, OAuth2Application
|
||||||
|
)
|
||||||
|
from awx.main.utils.encryption import (
|
||||||
|
encrypt_field, decrypt_field, encrypt_value, decrypt_value, get_encryption_key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Regenerate a new SECRET_KEY value and re-encrypt every secret in the
|
||||||
|
Tower database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, **options):
|
||||||
|
self.old_key = settings.SECRET_KEY
|
||||||
|
self.new_key = base64.encodebytes(os.urandom(33)).decode().rstrip()
|
||||||
|
self._notification_templates()
|
||||||
|
self._credentials()
|
||||||
|
self._unified_jobs()
|
||||||
|
self._oauth2_app_secrets()
|
||||||
|
self._settings()
|
||||||
|
self._survey_passwords()
|
||||||
|
return self.new_key
|
||||||
|
|
||||||
|
def _notification_templates(self):
|
||||||
|
for nt in NotificationTemplate.objects.iterator():
|
||||||
|
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NotificationTemplate.NOTIFICATION_TYPES])
|
||||||
|
notification_class = CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
|
||||||
|
for field in filter(lambda x: notification_class.init_parameters[x]['type'] == "password",
|
||||||
|
notification_class.init_parameters):
|
||||||
|
nt.notification_configuration[field] = decrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.old_key)
|
||||||
|
nt.notification_configuration[field] = encrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.new_key)
|
||||||
|
nt.save()
|
||||||
|
|
||||||
|
def _credentials(self):
|
||||||
|
for credential in Credential.objects.iterator():
|
||||||
|
for field_name in credential.credential_type.secret_fields:
|
||||||
|
if field_name in credential.inputs:
|
||||||
|
credential.inputs[field_name] = decrypt_field(
|
||||||
|
credential,
|
||||||
|
field_name,
|
||||||
|
secret_key=self.old_key
|
||||||
|
)
|
||||||
|
credential.inputs[field_name] = encrypt_field(
|
||||||
|
credential,
|
||||||
|
field_name,
|
||||||
|
secret_key=self.new_key
|
||||||
|
)
|
||||||
|
credential.save()
|
||||||
|
|
||||||
|
def _unified_jobs(self):
|
||||||
|
for uj in UnifiedJob.objects.iterator():
|
||||||
|
if uj.start_args:
|
||||||
|
uj.start_args = decrypt_field(
|
||||||
|
uj,
|
||||||
|
'start_args',
|
||||||
|
secret_key=self.old_key
|
||||||
|
)
|
||||||
|
uj.start_args = encrypt_field(uj, 'start_args', secret_key=self.new_key)
|
||||||
|
uj.save()
|
||||||
|
|
||||||
|
def _oauth2_app_secrets(self):
|
||||||
|
for app in OAuth2Application.objects.iterator():
|
||||||
|
raw = app.client_secret
|
||||||
|
app.client_secret = raw
|
||||||
|
encrypted = encrypt_value(raw, secret_key=self.new_key)
|
||||||
|
OAuth2Application.objects.filter(pk=app.pk).update(client_secret=encrypted)
|
||||||
|
|
||||||
|
def _settings(self):
|
||||||
|
# don't update memcached, the *actual* value isn't changing
|
||||||
|
post_save.disconnect(on_post_save_setting, sender=Setting)
|
||||||
|
for setting in Setting.objects.filter().order_by('pk'):
|
||||||
|
if settings_registry.is_setting_encrypted(setting.key):
|
||||||
|
setting.value = decrypt_field(setting, 'value', secret_key=self.old_key)
|
||||||
|
setting.value = encrypt_field(setting, 'value', secret_key=self.new_key)
|
||||||
|
setting.save()
|
||||||
|
|
||||||
|
def _survey_passwords(self):
|
||||||
|
for _type in (JobTemplate, WorkflowJobTemplate):
|
||||||
|
for jt in _type.objects.exclude(survey_spec={}):
|
||||||
|
changed = False
|
||||||
|
if jt.survey_spec.get('spec', []):
|
||||||
|
for field in jt.survey_spec['spec']:
|
||||||
|
if field.get('type') == 'password' and field.get('default', ''):
|
||||||
|
raw = decrypt_value(
|
||||||
|
get_encryption_key('value', None, secret_key=self.old_key),
|
||||||
|
field['default']
|
||||||
|
)
|
||||||
|
field['default'] = encrypt_value(
|
||||||
|
raw,
|
||||||
|
pk=None,
|
||||||
|
secret_key=self.new_key
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
jt.save(update_fields=["survey_spec"])
|
||||||
|
|
||||||
|
for _type in (Job, WorkflowJob):
|
||||||
|
for job in _type.objects.exclude(survey_passwords={}).iterator():
|
||||||
|
changed = False
|
||||||
|
for key in job.survey_passwords:
|
||||||
|
if key in job.extra_vars:
|
||||||
|
extra_vars = json.loads(job.extra_vars)
|
||||||
|
if not extra_vars.get(key):
|
||||||
|
continue
|
||||||
|
raw = decrypt_value(
|
||||||
|
get_encryption_key('value', None, secret_key=self.old_key),
|
||||||
|
extra_vars[key]
|
||||||
|
)
|
||||||
|
extra_vars[key] = encrypt_value(raw, pk=None, secret_key=self.new_key)
|
||||||
|
job.extra_vars = json.dumps(extra_vars)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
job.save(update_fields=["extra_vars"])
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.fernet import InvalidToken
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from django.conf import settings
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from awx.main import models
|
||||||
|
from awx.conf.models import Setting
|
||||||
|
from awx.main.management.commands import regenerate_secret_key
|
||||||
|
from awx.main.utils.encryption import encrypt_field, decrypt_field, encrypt_value
|
||||||
|
|
||||||
|
|
||||||
|
PREFIX = '$encrypted$UTF8$AESCBC$'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestKeyRegeneration:
|
||||||
|
|
||||||
|
def test_encrypted_ssh_password(self, credential):
|
||||||
|
# test basic decryption
|
||||||
|
assert credential.inputs['password'].startswith(PREFIX)
|
||||||
|
assert credential.get_input('password') == 'secret'
|
||||||
|
|
||||||
|
# re-key the credential
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
new_cred = models.Credential.objects.get(pk=credential.pk)
|
||||||
|
assert credential.inputs['password'] != new_cred.inputs['password']
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
new_cred.get_input('password')
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
assert new_cred.get_input('password') == 'secret'
|
||||||
|
|
||||||
|
def test_encrypted_setting_values(self):
|
||||||
|
# test basic decryption
|
||||||
|
settings.LOG_AGGREGATOR_PASSWORD = 'sensitive'
|
||||||
|
s = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
|
||||||
|
assert s.value.startswith(PREFIX)
|
||||||
|
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
|
||||||
|
|
||||||
|
# re-key the setting value
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
new_setting = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
|
||||||
|
assert s.value != new_setting.value
|
||||||
|
|
||||||
|
# wipe out the local cache so the value is pulled from the DB again
|
||||||
|
settings.cache.delete('LOG_AGGREGATOR_PASSWORD')
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
settings.LOG_AGGREGATOR_PASSWORD
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
|
||||||
|
|
||||||
|
def test_encrypted_notification_secrets(self, notification_template_with_encrypt):
|
||||||
|
# test basic decryption
|
||||||
|
nt = notification_template_with_encrypt
|
||||||
|
nc = nt.notification_configuration
|
||||||
|
assert nc['token'].startswith(PREFIX)
|
||||||
|
|
||||||
|
Slack = nt.CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
|
||||||
|
class TestBackend(Slack):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kw):
|
||||||
|
assert kw['token'] == 'token'
|
||||||
|
|
||||||
|
def send_messages(self, messages):
|
||||||
|
pass
|
||||||
|
|
||||||
|
nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
|
||||||
|
nt.notification_type = 'test'
|
||||||
|
nt.send('Subject', 'Body')
|
||||||
|
|
||||||
|
# re-key the notification config
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
new_nt = models.NotificationTemplate.objects.get(pk=nt.pk)
|
||||||
|
assert nt.notification_configuration['token'] != new_nt.notification_configuration['token']
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
new_nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
|
||||||
|
new_nt.notification_type = 'test'
|
||||||
|
new_nt.send('Subject', 'Body')
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
new_nt.send('Subject', 'Body')
|
||||||
|
|
||||||
|
def test_job_start_args(self, job_factory):
|
||||||
|
# test basic decryption
|
||||||
|
job = job_factory()
|
||||||
|
job.start_args = json.dumps({'foo': 'bar'})
|
||||||
|
job.start_args = encrypt_field(job, field_name='start_args')
|
||||||
|
job.save()
|
||||||
|
assert job.start_args.startswith(PREFIX)
|
||||||
|
|
||||||
|
# re-key the start_args
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
new_job = models.Job.objects.get(pk=job.pk)
|
||||||
|
assert new_job.start_args != job.start_args
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
decrypt_field(new_job, field_name='start_args')
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
assert json.loads(
|
||||||
|
decrypt_field(new_job, field_name='start_args')
|
||||||
|
) == {'foo': 'bar'}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('cls', ('JobTemplate', 'WorkflowJobTemplate'))
|
||||||
|
def test_survey_spec(self, inventory, project, survey_spec_factory, cls):
|
||||||
|
params = {}
|
||||||
|
if cls == 'JobTemplate':
|
||||||
|
params['inventory'] = inventory
|
||||||
|
params['project'] = project
|
||||||
|
# test basic decryption
|
||||||
|
jt = getattr(models, cls).objects.create(
|
||||||
|
name='Example Template',
|
||||||
|
survey_spec=survey_spec_factory([{
|
||||||
|
'variable': 'secret_key',
|
||||||
|
'default': encrypt_value('donttell', pk=None),
|
||||||
|
'type': 'password'
|
||||||
|
}]),
|
||||||
|
survey_enabled=True,
|
||||||
|
**params
|
||||||
|
)
|
||||||
|
job = jt.create_unified_job()
|
||||||
|
assert jt.survey_spec['spec'][0]['default'].startswith(PREFIX)
|
||||||
|
assert job.survey_passwords == {'secret_key': '$encrypted$'}
|
||||||
|
assert json.loads(job.decrypted_extra_vars())['secret_key'] == 'donttell'
|
||||||
|
|
||||||
|
# re-key the extra_vars
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
new_job = models.UnifiedJob.objects.get(pk=job.pk)
|
||||||
|
assert new_job.extra_vars != job.extra_vars
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
new_job.decrypted_extra_vars()
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
assert json.loads(
|
||||||
|
new_job.decrypted_extra_vars()
|
||||||
|
)['secret_key'] == 'donttell'
|
||||||
|
|
||||||
|
def test_oauth2_application_client_secret(self, oauth_application):
|
||||||
|
# test basic decryption
|
||||||
|
secret = oauth_application.client_secret
|
||||||
|
assert len(secret) == 128
|
||||||
|
|
||||||
|
# re-key the client_secret
|
||||||
|
new_key = regenerate_secret_key.Command().handle()
|
||||||
|
|
||||||
|
# verify that the old SECRET_KEY doesn't work
|
||||||
|
with pytest.raises(InvalidToken):
|
||||||
|
models.OAuth2Application.objects.get(
|
||||||
|
pk=oauth_application.pk
|
||||||
|
).client_secret
|
||||||
|
|
||||||
|
# verify that the new SECRET_KEY *does* work
|
||||||
|
with override_settings(SECRET_KEY=new_key):
|
||||||
|
assert models.OAuth2Application.objects.get(
|
||||||
|
pk=oauth_application.pk
|
||||||
|
).client_secret == secret
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
@@ -35,7 +37,7 @@ class Fernet256(Fernet):
|
|||||||
self._backend = backend
|
self._backend = backend
|
||||||
|
|
||||||
|
|
||||||
def get_encryption_key(field_name, pk=None):
|
def get_encryption_key(field_name, pk=None, secret_key=None):
|
||||||
'''
|
'''
|
||||||
Generate key for encrypted password based on field name,
|
Generate key for encrypted password based on field name,
|
||||||
``settings.SECRET_KEY``, and instance pk (if available).
|
``settings.SECRET_KEY``, and instance pk (if available).
|
||||||
@@ -46,19 +48,58 @@ def get_encryption_key(field_name, pk=None):
|
|||||||
'''
|
'''
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
h = hashlib.sha512()
|
h = hashlib.sha512()
|
||||||
h.update(smart_bytes(settings.SECRET_KEY))
|
h.update(smart_bytes(secret_key or settings.SECRET_KEY))
|
||||||
if pk is not None:
|
if pk is not None:
|
||||||
h.update(smart_bytes(str(pk)))
|
h.update(smart_bytes(str(pk)))
|
||||||
h.update(smart_bytes(field_name))
|
h.update(smart_bytes(field_name))
|
||||||
return base64.urlsafe_b64encode(h.digest())
|
return base64.urlsafe_b64encode(h.digest())
|
||||||
|
|
||||||
|
|
||||||
def encrypt_value(value, pk=None):
|
def encrypt_value(value, pk=None, secret_key=None):
|
||||||
|
#
|
||||||
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||||
|
#
|
||||||
|
# !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!!
|
||||||
|
#
|
||||||
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
||||||
return encrypt_field(TransientField(pk=pk, value=value), 'value')
|
return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key)
|
||||||
|
|
||||||
|
|
||||||
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None):
|
||||||
|
#
|
||||||
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||||
|
#
|
||||||
|
# !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!!
|
||||||
|
#
|
||||||
|
# You should know that this function is used in various places throughout
|
||||||
|
# AWX for symmetric encryption - generally it's used to encrypt sensitive
|
||||||
|
# values that we store in the AWX database (such as SSH private keys for
|
||||||
|
# credentials).
|
||||||
|
#
|
||||||
|
# If you're reading this function's code because you're thinking about
|
||||||
|
# using it to encrypt *something new*, please remember that AWX has
|
||||||
|
# official support for *regenerating* the SECRET_KEY (on which the
|
||||||
|
# symmetric key is based):
|
||||||
|
#
|
||||||
|
# $ awx-manage regenerate_secret_key
|
||||||
|
# $ setup.sh -k
|
||||||
|
#
|
||||||
|
# ...so you'll need to *also* add code to support the
|
||||||
|
# migration/re-encryption of these values (the code in question lives in
|
||||||
|
# `awx.main.management.commands.regenerate_secret_key`):
|
||||||
|
#
|
||||||
|
# For example, if you find that you're adding a new database column that is
|
||||||
|
# encrypted, in addition to calling `encrypt_field` in the appropriate
|
||||||
|
# places, you would also need to update the `awx-manage regenerate_secret_key`
|
||||||
|
# so that values are properly migrated when the SECRET_KEY changes.
|
||||||
|
#
|
||||||
|
# This process *generally* involves adding Python code to the
|
||||||
|
# `regenerate_secret_key` command, i.e.,
|
||||||
|
#
|
||||||
|
# 1. Query the database for existing encrypted values on the appropriate object(s)
|
||||||
|
# 2. Decrypting them using the *old* SECRET_KEY
|
||||||
|
# 3. Storing newly encrypted values using the *newly generated* SECRET_KEY
|
||||||
|
#
|
||||||
'''
|
'''
|
||||||
Return content of the given instance and field name encrypted.
|
Return content of the given instance and field name encrypted.
|
||||||
'''
|
'''
|
||||||
@@ -76,7 +117,11 @@ def encrypt_field(instance, field_name, ask=False, subfield=None):
|
|||||||
value = smart_str(value)
|
value = smart_str(value)
|
||||||
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
|
||||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
key = get_encryption_key(
|
||||||
|
field_name,
|
||||||
|
getattr(instance, 'pk', None),
|
||||||
|
secret_key=secret_key
|
||||||
|
)
|
||||||
f = Fernet256(key)
|
f = Fernet256(key)
|
||||||
encrypted = f.encrypt(smart_bytes(value))
|
encrypted = f.encrypt(smart_bytes(value))
|
||||||
b64data = smart_str(base64.b64encode(encrypted))
|
b64data = smart_str(base64.b64encode(encrypted))
|
||||||
@@ -99,7 +144,7 @@ def decrypt_value(encryption_key, value):
|
|||||||
return smart_str(value)
|
return smart_str(value)
|
||||||
|
|
||||||
|
|
||||||
def decrypt_field(instance, field_name, subfield=None):
|
def decrypt_field(instance, field_name, subfield=None, secret_key=None):
|
||||||
'''
|
'''
|
||||||
Return content of the given instance and field name decrypted.
|
Return content of the given instance and field name decrypted.
|
||||||
'''
|
'''
|
||||||
@@ -115,7 +160,11 @@ def decrypt_field(instance, field_name, subfield=None):
|
|||||||
value = smart_str(value)
|
value = smart_str(value)
|
||||||
if not value or not value.startswith('$encrypted$'):
|
if not value or not value.startswith('$encrypted$'):
|
||||||
return value
|
return value
|
||||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
key = get_encryption_key(
|
||||||
|
field_name,
|
||||||
|
getattr(instance, 'pk', None),
|
||||||
|
secret_key=secret_key
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return smart_str(decrypt_value(key, value))
|
return smart_str(decrypt_value(key, value))
|
||||||
|
|||||||
Reference in New Issue
Block a user