1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 15:21:13 +03:00

Merge pull request #6089 from ryanpetrello/credential_type_injectors

Implement custom credential type injectors
This commit is contained in:
Ryan Petrello 2017-04-25 10:27:00 -04:00 committed by GitHub
commit 12f244495c
6 changed files with 333 additions and 30 deletions

View File

@ -699,24 +699,6 @@ class CredentialTypeInjectorField(JSONSchemaField):
'additionalProperties': False, 'additionalProperties': False,
'required': ['template'], 'required': ['template'],
}, },
'ssh': {
'type': 'object',
'properties': {
'private': {'type': 'string'},
'public': {'type': 'string'},
},
'additionalProperties': False,
'required': ['public', 'private'],
},
'password': {
'type': 'object',
'properties': {
'key': {'type': 'string'},
'value': {'type': 'string'},
},
'additionalProperties': False,
'required': ['key', 'value'],
},
'env': { 'env': {
'type': 'object', 'type': 'object',
'patternProperties': { 'patternProperties': {

View File

@ -2,7 +2,14 @@
# All Rights Reserved. # All Rights Reserved.
from collections import OrderedDict from collections import OrderedDict
import functools import functools
import json
import operator import operator
import os
import stat
import tempfile
# Jinja2
from jinja2 import Template
# Django # Django
from django.db import models from django.db import models
@ -450,6 +457,14 @@ class CredentialType(CommonModelNameNotUnique):
defaults = OrderedDict() defaults = OrderedDict()
ENV_BLACKLIST = set((
'VIRTUAL_ENV', 'PATH', 'PYTHONPATH', 'PROOT_TMP_DIR', 'JOB_ID',
'INVENTORY_ID', 'INVENTORY_SOURCE_ID', 'INVENTORY_UPDATE_ID',
'AD_HOC_COMMAND_ID', 'REST_API_URL', 'REST_API_TOKEN', 'TOWER_HOST',
'MAX_EVENT_RES', 'CALLBACK_QUEUE', 'CALLBACK_CONNECTION', 'CACHE',
'JOB_CALLBACK_DEBUG', 'INVENTORY_HOSTVARS', 'FACT_QUEUE',
))
class Meta: class Meta:
app_label = 'main' app_label = 'main'
ordering = ('kind', 'name') ordering = ('kind', 'name')
@ -539,6 +554,90 @@ class CredentialType(CommonModelNameNotUnique):
match = cls.objects.filter(**requirements)[:1].get() match = cls.objects.filter(**requirements)[:1].get()
return match return match
def inject_credential(self, credential, env, safe_env, args, safe_args, private_data_dir):
"""
Inject credential data into the environment variables and arguments
passed to `ansible-playbook`
:param credential: a :class:`awx.main.models.Credential` instance
:param env: a dictionary of environment variables used in
the `ansible-playbook` call. This method adds
additional environment variables based on
custom `env` injectors defined on this
CredentialType.
:param safe_env: a dictionary of environment variables stored
in the database for the job run
(`UnifiedJob.job_env`); secret values should
be stripped
:param args: a list of arguments passed to
`ansible-playbook` in the style of
`subprocess.call(args)`. This method appends
additional arguments based on custom
`extra_vars` injectors defined on this
CredentialType.
:param safe_args: a list of arguments stored in the database for
the job run (`UnifiedJob.job_args`); secret
values should be stripped
:param private_data_dir: a temporary directory to store files generated
by `file` injectors (like config files or key
files)
"""
if not self.injectors:
return
class TowerNamespace:
filename = None
tower_namespace = TowerNamespace()
# maintain a normal namespace for building the ansible-playbook arguments (env and args)
namespace = {'tower': tower_namespace}
# maintain a sanitized namespace for building the DB-stored arguments (safe_env and safe_args)
safe_namespace = {'tower': tower_namespace}
# build a normal namespace with secret values decrypted (for
# ansible-playbook) and a safe namespace with secret values hidden (for
# DB storage)
for field_name, value in credential.inputs.items():
if field_name in self.secret_fields:
value = decrypt_field(credential, field_name)
safe_namespace[field_name] = '**********'
elif len(value):
safe_namespace[field_name] = value
if len(value):
namespace[field_name] = value
file_tmpl = self.injectors.get('file', {}).get('template')
if file_tmpl is not None:
# If a file template is provided, render the file and update the
# special `tower` template namespace so the filename can be
# referenced in other injectors
data = Template(file_tmpl).render(**namespace)
_, path = tempfile.mkstemp(dir=private_data_dir)
with open(path, 'w') as f:
f.write(data)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
namespace['tower'].filename = path
for env_var, tmpl in self.injectors.get('env', {}).items():
if env_var.startswith('ANSIBLE_') or env_var in self.ENV_BLACKLIST:
continue
env[env_var] = Template(tmpl).render(**namespace)
safe_env[env_var] = Template(tmpl).render(**safe_namespace)
extra_vars = {}
safe_extra_vars = {}
for var_name, tmpl in self.injectors.get('extra_vars', {}).items():
extra_vars[var_name] = Template(tmpl).render(**namespace)
safe_extra_vars[var_name] = Template(tmpl).render(**safe_namespace)
if extra_vars:
args.extend(['-e', json.dumps(extra_vars)])
if safe_extra_vars:
safe_args.extend(['-e', json.dumps(safe_extra_vars)])
@CredentialType.default @CredentialType.default
def ssh(cls): def ssh(cls):

View File

@ -979,7 +979,7 @@ class InventorySourceOptions(BaseModel):
if not self.source: if not self.source:
return None return None
cred = self.credential cred = self.credential
if cred: if cred and self.source != 'custom':
# If a credential was provided, it's important that it matches # If a credential was provided, it's important that it matches
# the actual inventory source being used (Amazon requires Amazon # the actual inventory source being used (Amazon requires Amazon
# credentials; Rackspace requires Rackspace credentials; etc...) # credentials; Rackspace requires Rackspace credentials; etc...)

View File

@ -705,6 +705,15 @@ class BaseTask(Task):
cwd = self.build_cwd(instance, **kwargs) cwd = self.build_cwd(instance, **kwargs)
env = self.build_env(instance, **kwargs) env = self.build_env(instance, **kwargs)
safe_env = self.build_safe_env(env, **kwargs) safe_env = self.build_safe_env(env, **kwargs)
# handle custom injectors specified on the CredentialType
for type_ in ('credential', 'cloud_credential', 'network_credential'):
credential = getattr(instance, type_, None)
if credential:
credential.credential_type.inject_credential(
credential, env, safe_env, args, safe_args, kwargs['private_data_dir']
)
stdout_handle = self.get_stdout_handle(instance) stdout_handle = self.get_stdout_handle(instance)
if self.should_use_proot(instance, **kwargs): if self.should_use_proot(instance, **kwargs):
if not check_proot_installed(): if not check_proot_installed():

View File

@ -94,17 +94,6 @@ def test_cred_type_input_schema_validity(input_, valid):
({'file': {}}, False), ({'file': {}}, False),
({'file': {'template': '{{username}}'}}, True), ({'file': {'template': '{{username}}'}}, True),
({'file': {'foo': 'bar'}}, False), ({'file': {'foo': 'bar'}}, False),
({'ssh': 123}, False),
({'ssh': {}}, False),
({'ssh': {'public': 'PUB'}}, False),
({'ssh': {'private': 'PRIV'}}, False),
({'ssh': {'public': 'PUB', 'private': 'PRIV'}}, True),
({'ssh': {'public': 'PUB', 'private': 'PRIV', 'a': 'b'}}, False),
({'password': {}}, False),
({'password': {'key': 'Password:'}}, False),
({'password': {'value': '{{pass}}'}}, False),
({'password': {'key': 'Password:', 'value': '{{pass}}'}}, True),
({'password': {'key': 'Password:', 'value': '{{pass}}', 'a': 'b'}}, False),
({'env': 123}, False), ({'env': 123}, False),
({'env': {}}, True), ({'env': {}}, True),
({'env': {'AWX_SECRET': '{{awx_secret}}'}}, True), ({'env': {'AWX_SECRET': '{{awx_secret}}'}}, True),

View File

@ -2,6 +2,7 @@ from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
import ConfigParser import ConfigParser
import json
import tempfile import tempfile
import pytest import pytest
@ -563,6 +564,229 @@ class TestJobCredentials(TestJobExecution):
self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect) self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect)
self.task.run(self.pk) self.task.run(self.pk)
def test_custom_environment_injectors_with_jinja_syntax_error(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'api_token',
'label': 'API Token',
'type': 'string'
}]
},
injectors={
'env': {
'MY_CLOUD_API_TOKEN': '{{api_token.foo()}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'api_token': 'ABC123'}
)
with pytest.raises(Exception):
self.task.run(self.pk)
def test_custom_environment_injectors(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'api_token',
'label': 'API Token',
'type': 'string'
}]
},
injectors={
'env': {
'MY_CLOUD_API_TOKEN': '{{api_token}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'api_token': 'ABC123'}
)
self.task.run(self.pk)
assert self.task.run_pexpect.call_count == 1
call_args, _ = self.task.run_pexpect.call_args_list[0]
job, args, cwd, env, passwords, stdout = call_args
assert env['MY_CLOUD_API_TOKEN'] == 'ABC123'
def test_custom_environment_injectors_with_reserved_env_var(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'api_token',
'label': 'API Token',
'type': 'string'
}]
},
injectors={
'env': {
'JOB_ID': 'reserved'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'api_token': 'ABC123'}
)
self.task.run(self.pk)
assert self.task.run_pexpect.call_count == 1
call_args, _ = self.task.run_pexpect.call_args_list[0]
job, args, cwd, env, passwords, stdout = call_args
assert env['JOB_ID'] == str(self.instance.pk)
def test_custom_environment_injectors_with_secret_field(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'password',
'label': 'Password',
'type': 'string',
'secret': True
}]
},
injectors={
'env': {
'MY_CLOUD_PRIVATE_VAR': '{{password}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'password': 'SUPER-SECRET-123'}
)
self.instance.cloud_credential.inputs['password'] = encrypt_field(
self.instance.cloud_credential, 'password'
)
self.task.run(self.pk)
assert self.task.run_pexpect.call_count == 1
call_args, _ = self.task.run_pexpect.call_args_list[0]
job, args, cwd, env, passwords, stdout = call_args
assert env['MY_CLOUD_PRIVATE_VAR'] == 'SUPER-SECRET-123'
assert 'SUPER-SECRET-123' not in json.dumps(self.task.update_model.call_args_list)
def test_custom_environment_injectors_with_extra_vars(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'api_token',
'label': 'API Token',
'type': 'string'
}]
},
injectors={
'extra_vars': {
'api_token': '{{api_token}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'api_token': 'ABC123'}
)
self.task.run(self.pk)
assert self.task.run_pexpect.call_count == 1
call_args, _ = self.task.run_pexpect.call_args_list[0]
job, args, cwd, env, passwords, stdout = call_args
assert '-e {"api_token": "ABC123"}' in ' '.join(args)
def test_custom_environment_injectors_with_secret_extra_vars(self):
"""
extra_vars that contain secret field values should be censored in the DB
"""
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'password',
'label': 'Password',
'type': 'string',
'secret': True
}]
},
injectors={
'extra_vars': {
'password': '{{password}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'password': 'SUPER-SECRET-123'}
)
self.instance.cloud_credential.inputs['password'] = encrypt_field(
self.instance.cloud_credential, 'password'
)
self.task.run(self.pk)
assert self.task.run_pexpect.call_count == 1
call_args, _ = self.task.run_pexpect.call_args_list[0]
job, args, cwd, env, passwords, stdout = call_args
assert '-e {"password": "SUPER-SECRET-123"}' in ' '.join(args)
assert 'SUPER-SECRET-123' not in json.dumps(self.task.update_model.call_args_list)
def test_custom_environment_injectors_with_file(self):
some_cloud = CredentialType(
kind='cloud',
name='SomeCloud',
managed_by_tower=False,
inputs={
'fields': [{
'id': 'api_token',
'label': 'API Token',
'type': 'string'
}]
},
injectors={
'file': {
'template': '[mycloud]\n{{api_token}}'
},
'env': {
'MY_CLOUD_INI_FILE': '{{tower.filename}}'
}
}
)
self.instance.cloud_credential = Credential(
credential_type=some_cloud,
inputs = {'api_token': 'ABC123'}
)
self.task.run(self.pk)
def run_pexpect_side_effect(*args, **kwargs):
job, args, cwd, env, passwords, stdout = args
assert open(env['MY_CLOUD_INI_FILE'], 'rb').read() == '[mycloud]\nABC123'
return ['successful', 0]
self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect)
self.task.run(self.pk)
class TestProjectUpdateCredentials(TestJobExecution): class TestProjectUpdateCredentials(TestJobExecution):