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:
commit
12f244495c
@ -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': {
|
||||||
|
@ -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):
|
||||||
|
@ -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...)
|
||||||
|
@ -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():
|
||||||
|
@ -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),
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user