mirror of
https://github.com/ansible/awx.git
synced 2026-02-15 18:20:00 -03:30
encrypt job survey data
see: https://github.com/ansible/ansible-tower/issues/7046
This commit is contained in:
committed by
Matthew Jones
parent
9978b3f9ad
commit
4be4e3db7f
@@ -15,6 +15,7 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
import six
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -72,6 +73,7 @@ from awx.main.utils import (
|
|||||||
extract_ansible_vars,
|
extract_ansible_vars,
|
||||||
decrypt_field,
|
decrypt_field,
|
||||||
)
|
)
|
||||||
|
from awx.main.utils.encryption import encrypt_value
|
||||||
from awx.main.utils.filters import SmartFilter
|
from awx.main.utils.filters import SmartFilter
|
||||||
from awx.main.utils.insights import filter_insights_api_response
|
from awx.main.utils.insights import filter_insights_api_response
|
||||||
|
|
||||||
@@ -2899,8 +2901,15 @@ class JobTemplateSurveySpec(GenericAPIView):
|
|||||||
if "required" not in survey_item:
|
if "required" not in survey_item:
|
||||||
return Response(dict(error=_("'required' missing from survey question %s.") % str(idx)), status=status.HTTP_400_BAD_REQUEST)
|
return Response(dict(error=_("'required' missing from survey question %s.") % str(idx)), status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if survey_item["type"] == "password":
|
if survey_item["type"] == "password" and "default" in survey_item:
|
||||||
if survey_item.get("default") and survey_item["default"].startswith('$encrypted$'):
|
if not isinstance(survey_item['default'], six.string_types):
|
||||||
|
return Response(
|
||||||
|
_("Value %s for '%s' expected to be a string." % (
|
||||||
|
survey_item["default"], survey_item["variable"]
|
||||||
|
)),
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
elif survey_item["default"].startswith('$encrypted$'):
|
||||||
if not obj.survey_spec:
|
if not obj.survey_spec:
|
||||||
return Response(dict(error=_("$encrypted$ is reserved keyword and may not be used as a default for password {}.".format(str(idx)))),
|
return Response(dict(error=_("$encrypted$ is reserved keyword and may not be used as a default for password {}.".format(str(idx)))),
|
||||||
status=status.HTTP_400_BAD_REQUEST)
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -2909,6 +2918,8 @@ class JobTemplateSurveySpec(GenericAPIView):
|
|||||||
for old_item in old_spec['spec']:
|
for old_item in old_spec['spec']:
|
||||||
if old_item['variable'] == survey_item['variable']:
|
if old_item['variable'] == survey_item['variable']:
|
||||||
survey_item['default'] = old_item['default']
|
survey_item['default'] = old_item['default']
|
||||||
|
else:
|
||||||
|
survey_item['default'] = encrypt_value(survey_item['default'])
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
obj.survey_spec = new_spec
|
obj.survey_spec = new_spec
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from awx.main.utils import (
|
|||||||
ignore_inventory_computed_fields,
|
ignore_inventory_computed_fields,
|
||||||
parse_yaml_or_json,
|
parse_yaml_or_json,
|
||||||
)
|
)
|
||||||
|
from awx.main.utils.encryption import encrypt_value
|
||||||
from awx.main.fields import ImplicitRoleField
|
from awx.main.fields import ImplicitRoleField
|
||||||
from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin
|
from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin
|
||||||
from awx.main.models.base import PERM_INVENTORY_SCAN
|
from awx.main.models.base import PERM_INVENTORY_SCAN
|
||||||
@@ -385,6 +386,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
# Sort the runtime fields allowed and disallowed by job template
|
# Sort the runtime fields allowed and disallowed by job template
|
||||||
ignored_fields = {}
|
ignored_fields = {}
|
||||||
prompted_fields = {}
|
prompted_fields = {}
|
||||||
|
survey_password_variables = self.survey_password_variables()
|
||||||
|
|
||||||
ask_for_vars_dict = self._ask_for_vars_dict()
|
ask_for_vars_dict = self._ask_for_vars_dict()
|
||||||
|
|
||||||
@@ -402,7 +404,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
extra_vars = parse_yaml_or_json(kwargs[field])
|
extra_vars = parse_yaml_or_json(kwargs[field])
|
||||||
for key in extra_vars:
|
for key in extra_vars:
|
||||||
if key in survey_vars:
|
if key in survey_vars:
|
||||||
prompted_fields[field][key] = extra_vars[key]
|
if key in survey_password_variables:
|
||||||
|
prompted_fields[field][key] = encrypt_value(extra_vars[key])
|
||||||
|
else:
|
||||||
|
prompted_fields[field][key] = extra_vars[key]
|
||||||
else:
|
else:
|
||||||
ignored_fields[field][key] = extra_vars[key]
|
ignored_fields[field][key] = extra_vars[key]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from awx.main.models.rbac import (
|
|||||||
Role, RoleAncestorEntry, get_roles_on_resource
|
Role, RoleAncestorEntry, get_roles_on_resource
|
||||||
)
|
)
|
||||||
from awx.main.utils import parse_yaml_or_json
|
from awx.main.utils import parse_yaml_or_json
|
||||||
|
from awx.main.utils.encryption import decrypt_value, get_encryption_key
|
||||||
from awx.main.fields import JSONField
|
from awx.main.fields import JSONField
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +264,20 @@ class SurveyJobMixin(models.Model):
|
|||||||
else:
|
else:
|
||||||
return self.extra_vars
|
return self.extra_vars
|
||||||
|
|
||||||
|
def decrypted_extra_vars(self):
|
||||||
|
'''
|
||||||
|
Decrypts fields marked as passwords in survey.
|
||||||
|
'''
|
||||||
|
if self.survey_passwords:
|
||||||
|
extra_vars = json.loads(self.extra_vars)
|
||||||
|
for key in self.survey_passwords:
|
||||||
|
if key in extra_vars:
|
||||||
|
value = extra_vars[key]
|
||||||
|
extra_vars[key] = decrypt_value(get_encryption_key('value', pk=None), value)
|
||||||
|
return json.dumps(extra_vars)
|
||||||
|
else:
|
||||||
|
return self.extra_vars
|
||||||
|
|
||||||
|
|
||||||
class TaskManagerUnifiedJobMixin(models.Model):
|
class TaskManagerUnifiedJobMixin(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1180,7 +1180,7 @@ class RunJob(BaseTask):
|
|||||||
if kwargs.get('display', False) and job.job_template:
|
if kwargs.get('display', False) and job.job_template:
|
||||||
extra_vars.update(json.loads(job.display_extra_vars()))
|
extra_vars.update(json.loads(job.display_extra_vars()))
|
||||||
else:
|
else:
|
||||||
extra_vars.update(job.extra_vars_dict)
|
extra_vars.update(json.loads(job.decrypted_extra_vars()))
|
||||||
args.extend(['-e', json.dumps(extra_vars)])
|
args.extend(['-e', json.dumps(extra_vars)])
|
||||||
|
|
||||||
# Add path to playbook (relative to project.local_path).
|
# Add path to playbook (relative to project.local_path).
|
||||||
|
|||||||
@@ -91,6 +91,111 @@ def test_survey_spec_sucessful_creation(survey_spec_factory, job_template, post,
|
|||||||
assert updated_jt.survey_spec == survey_input_data
|
assert updated_jt.survey_spec == survey_input_data
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('awx.api.views.feature_enabled', lambda feature: True)
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize('value, status', [
|
||||||
|
('SUPERSECRET', 201),
|
||||||
|
(['some', 'invalid', 'list'], 400),
|
||||||
|
({'some-invalid': 'dict'}, 400),
|
||||||
|
(False, 400)
|
||||||
|
])
|
||||||
|
def test_survey_spec_passwords_are_encrypted_on_launch(job_template_factory, post, admin_user, value, status):
|
||||||
|
objects = job_template_factory('jt', organization='org1', project='prj',
|
||||||
|
inventory='inv', credential='cred')
|
||||||
|
job_template = objects.job_template
|
||||||
|
job_template.survey_enabled = True
|
||||||
|
job_template.save()
|
||||||
|
input_data = {
|
||||||
|
'description': 'A survey',
|
||||||
|
'spec': [{
|
||||||
|
'index': 0,
|
||||||
|
'question_name': 'What is your password?',
|
||||||
|
'required': True,
|
||||||
|
'variable': 'secret_value',
|
||||||
|
'type': 'password'
|
||||||
|
}],
|
||||||
|
'name': 'my survey'
|
||||||
|
}
|
||||||
|
post(url=reverse('api:job_template_survey_spec', kwargs={'pk': job_template.id}),
|
||||||
|
data=input_data, user=admin_user, expect=200)
|
||||||
|
resp = post(reverse('api:job_template_launch', kwargs={'pk': job_template.pk}),
|
||||||
|
dict(extra_vars=dict(secret_value=value)), admin_user, expect=status)
|
||||||
|
|
||||||
|
if status == 201:
|
||||||
|
job = Job.objects.get(pk=resp.data['id'])
|
||||||
|
assert json.loads(job.extra_vars)['secret_value'].startswith('$encrypted$')
|
||||||
|
assert json.loads(job.decrypted_extra_vars()) == {
|
||||||
|
'secret_value': value
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
assert "for 'secret_value' expected to be a string." in json.dumps(resp.data)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('awx.api.views.feature_enabled', lambda feature: True)
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize('default, status', [
|
||||||
|
('SUPERSECRET', 200),
|
||||||
|
(['some', 'invalid', 'list'], 400),
|
||||||
|
({'some-invalid': 'dict'}, 400),
|
||||||
|
(False, 400)
|
||||||
|
])
|
||||||
|
def test_survey_spec_default_passwords_are_encrypted(job_template, post, admin_user, default, status):
|
||||||
|
job_template.survey_enabled = True
|
||||||
|
job_template.save()
|
||||||
|
input_data = {
|
||||||
|
'description': 'A survey',
|
||||||
|
'spec': [{
|
||||||
|
'index': 0,
|
||||||
|
'question_name': 'What is your password?',
|
||||||
|
'required': True,
|
||||||
|
'variable': 'secret_value',
|
||||||
|
'default': default,
|
||||||
|
'type': 'password'
|
||||||
|
}],
|
||||||
|
'name': 'my survey'
|
||||||
|
}
|
||||||
|
resp = post(url=reverse('api:job_template_survey_spec', kwargs={'pk': job_template.id}),
|
||||||
|
data=input_data, user=admin_user, expect=status)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
updated_jt = JobTemplate.objects.get(pk=job_template.pk)
|
||||||
|
assert updated_jt.survey_spec['spec'][0]['default'].startswith('$encrypted$')
|
||||||
|
|
||||||
|
job = updated_jt.create_unified_job()
|
||||||
|
assert json.loads(job.extra_vars)['secret_value'].startswith('$encrypted$')
|
||||||
|
assert json.loads(job.decrypted_extra_vars()) == {
|
||||||
|
'secret_value': default
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
assert "for 'secret_value' expected to be a string." in str(resp.data)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('awx.api.views.feature_enabled', lambda feature: True)
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_survey_spec_default_passwords_encrypted_on_update(job_template, post, put, admin_user):
|
||||||
|
input_data = {
|
||||||
|
'description': 'A survey',
|
||||||
|
'spec': [{
|
||||||
|
'index': 0,
|
||||||
|
'question_name': 'What is your password?',
|
||||||
|
'required': True,
|
||||||
|
'variable': 'secret_value',
|
||||||
|
'default': 'SUPERSECRET',
|
||||||
|
'type': 'password'
|
||||||
|
}],
|
||||||
|
'name': 'my survey'
|
||||||
|
}
|
||||||
|
post(url=reverse('api:job_template_survey_spec', kwargs={'pk': job_template.id}),
|
||||||
|
data=input_data, user=admin_user, expect=200)
|
||||||
|
updated_jt = JobTemplate.objects.get(pk=job_template.pk)
|
||||||
|
|
||||||
|
# simulate a survey field edit where we're not changing the default value
|
||||||
|
input_data['spec'][0]['default'] = '$encrypted$'
|
||||||
|
post(url=reverse('api:job_template_survey_spec', kwargs={'pk': job_template.id}),
|
||||||
|
data=input_data, user=admin_user, expect=200)
|
||||||
|
assert updated_jt.survey_spec == JobTemplate.objects.get(pk=job_template.pk).survey_spec
|
||||||
|
|
||||||
|
|
||||||
# Tests related to survey content validation
|
# Tests related to survey content validation
|
||||||
@mock.patch('awx.api.views.feature_enabled', lambda feature: True)
|
@mock.patch('awx.api.views.feature_enabled', lambda feature: True)
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from awx.main.models import (
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def job(mocker):
|
def job(mocker):
|
||||||
ret = mocker.MagicMock(**{
|
ret = mocker.MagicMock(**{
|
||||||
|
'decrypted_extra_vars.return_value': '{\"secret_key\": \"my_password\"}',
|
||||||
'display_extra_vars.return_value': '{\"secret_key\": \"$encrypted$\"}',
|
'display_extra_vars.return_value': '{\"secret_key\": \"$encrypted$\"}',
|
||||||
'extra_vars_dict': {"secret_key": "my_password"},
|
'extra_vars_dict': {"secret_key": "my_password"},
|
||||||
'pk': 1, 'job_template.pk': 1, 'job_template.name': '',
|
'pk': 1, 'job_template.pk': 1, 'job_template.name': '',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from awx.main.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from awx.main import tasks
|
from awx.main import tasks
|
||||||
from awx.main.utils import encrypt_field
|
from awx.main.utils import encrypt_field, encrypt_value
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -306,6 +306,20 @@ class TestGenericRun(TestJobExecution):
|
|||||||
assert '"awx_user_id": 123,' in ' '.join(args)
|
assert '"awx_user_id": 123,' in ' '.join(args)
|
||||||
assert '"awx_user_name": "angry-spud"' in ' '.join(args)
|
assert '"awx_user_name": "angry-spud"' in ' '.join(args)
|
||||||
|
|
||||||
|
def test_survey_extra_vars(self):
|
||||||
|
self.instance.extra_vars = json.dumps({
|
||||||
|
'super_secret': encrypt_value('CLASSIFIED', pk=None)
|
||||||
|
})
|
||||||
|
self.instance.survey_passwords = {
|
||||||
|
'super_secret': '$encrypted$'
|
||||||
|
}
|
||||||
|
self.task.run(self.pk)
|
||||||
|
|
||||||
|
assert self.run_pexpect.call_count == 1
|
||||||
|
call_args, _ = self.run_pexpect.call_args_list[0]
|
||||||
|
args, cwd, env, stdout = call_args
|
||||||
|
assert '"super_secret": "CLASSIFIED"' in ' '.join(args)
|
||||||
|
|
||||||
def test_awx_task_env(self):
|
def test_awx_task_env(self):
|
||||||
patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'})
|
patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'})
|
||||||
patch.start()
|
patch.start()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
import six
|
import six
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
@@ -8,7 +9,8 @@ from cryptography.hazmat.backends import default_backend
|
|||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['get_encryption_key', 'encrypt_field', 'decrypt_field', 'decrypt_value']
|
__all__ = ['get_encryption_key', 'encrypt_value', 'encrypt_field',
|
||||||
|
'decrypt_field', 'decrypt_value']
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.utils.encryption')
|
logger = logging.getLogger('awx.main.utils.encryption')
|
||||||
|
|
||||||
@@ -50,6 +52,11 @@ def get_encryption_key(field_name, pk=None):
|
|||||||
return base64.urlsafe_b64encode(h.digest())
|
return base64.urlsafe_b64encode(h.digest())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(value, pk=None):
|
||||||
|
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
||||||
|
return encrypt_field(TransientField(pk=pk, value=value), 'value')
|
||||||
|
|
||||||
|
|
||||||
def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False):
|
def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False):
|
||||||
'''
|
'''
|
||||||
Return content of the given instance and field name encrypted.
|
Return content of the given instance and field name encrypted.
|
||||||
|
|||||||
Reference in New Issue
Block a user