Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new credential entry point discovery #15685

Open
wants to merge 4 commits into
base: devel
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 45 additions & 161 deletions awx/main/models/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# All Rights Reserved.
from contextlib import nullcontext
import functools

import inspect
import logging
import os
Expand Down Expand Up @@ -53,6 +54,8 @@
)
from awx.main.models import Team, Organization
from awx.main.utils import encrypt_field
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name


# DAB
from ansible_base.resource_registry.tasks.sync import get_resource_server_client
Expand All @@ -62,7 +65,6 @@
__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'build_safe_env']

logger = logging.getLogger('awx.main.models.credential')
credential_plugins = {entry_point.name: entry_point.load() for entry_point in entry_points(group='awx_plugins.credentials')}

HIDDEN_PASSWORD = '**********'

Expand Down Expand Up @@ -446,7 +448,7 @@ def from_db(cls, db, field_names, values):
native = ManagedCredentialType.registry[instance.namespace]
instance.inputs = native.inputs
instance.injectors = native.injectors
instance.custom_injectors = native.custom_injectors
instance.custom_injectors = getattr(native, 'custom_injectors', None)
return instance

def get_absolute_url(self, request=None):
Expand Down Expand Up @@ -480,7 +482,7 @@ def default_for_field(self, field_id):

@classproperty
def defaults(cls):
return dict((k, functools.partial(v.create)) for k, v in ManagedCredentialType.registry.items())
return dict((k, functools.partial(CredentialTypeHelper.create, v)) for k, v in ManagedCredentialType.registry.items())

@classmethod
def _get_credential_type_class(cls, apps: Apps = None, app_config: AppConfig = None):
Expand Down Expand Up @@ -515,7 +517,7 @@ def _setup_tower_managed_defaults(cls, apps: Apps = None, app_config: AppConfig
existing.save()
continue
logger.debug(_("adding %s credential type" % default.name))
params = default.get_creation_params()
params = CredentialTypeHelper.get_creation_params(default)
if 'managed' not in [f.name for f in ct_class._meta.get_fields()]:
params['managed_by_tower'] = params.pop('managed')
params['created'] = params['modified'] = now() # CreatedModifiedModel service
Expand Down Expand Up @@ -549,170 +551,33 @@ def setup_tower_managed_defaults(cls, apps: Apps = None, app_config: AppConfig =
@classmethod
def load_plugin(cls, ns, plugin):
# TODO: User "side-loaded" credential custom_injectors isn't supported
ManagedCredentialType(namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs)
ManagedCredentialType.registry[ns] = ManagedCredentialType(namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs, injectors={})

def inject_credential(self, credential, env, safe_env, 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 private_data_dir: a temporary directory to store files generated
by `file` injectors (like config files or key
files)
"""
if not self.injectors:
if self.managed and credential.credential_type.custom_injectors:
injected_env = {}
credential.credential_type.custom_injectors(credential, injected_env, private_data_dir)
env.update(injected_env)
safe_env.update(build_safe_env(injected_env))
return

class TowerNamespace:
pass

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)
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 in credential.get_input_keys():
value = credential.get_input(field_name)

if type(value) is bool:
# boolean values can't be secret/encrypted/external
safe_namespace[field_name] = namespace[field_name] = value
continue

if field_name in self.secret_fields:
safe_namespace[field_name] = '**********'
elif len(value):
safe_namespace[field_name] = value
if len(value):
namespace[field_name] = value

for field in self.inputs.get('fields', []):
# default missing boolean fields to False
if field['type'] == 'boolean' and field['id'] not in credential.inputs.keys():
namespace[field['id']] = safe_namespace[field['id']] = False
# make sure private keys end with a \n
if field.get('format') == 'ssh_private_key':
if field['id'] in namespace and not namespace[field['id']].endswith('\n'):
namespace[field['id']] += '\n'

file_tmpls = self.injectors.get('file', {})
# If any file templates are provided, render the files and update the
# special `tower` template namespace so the filename can be
# referenced in other injectors

sandbox_env = sandbox.ImmutableSandboxedEnvironment()

for file_label, file_tmpl in file_tmpls.items():
data = sandbox_env.from_string(file_tmpl).render(**namespace)
_, path = tempfile.mkstemp(dir=os.path.join(private_data_dir, 'env'))
with open(path, 'w') as f:
f.write(data)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
container_path = get_incontainer_path(path, private_data_dir)

# determine if filename indicates single file or many
if file_label.find('.') == -1:
tower_namespace.filename = container_path
else:
if not hasattr(tower_namespace, 'filename'):
tower_namespace.filename = TowerNamespace()
file_label = file_label.split('.')[1]
setattr(tower_namespace.filename, file_label, container_path)

injector_field = self._meta.get_field('injectors')
for env_var, tmpl in self.injectors.get('env', {}).items():
try:
injector_field.validate_env_var_allowed(env_var)
except ValidationError as e:
logger.error('Ignoring prohibited env var {}, reason: {}'.format(env_var, e))
continue
env[env_var] = sandbox_env.from_string(tmpl).render(**namespace)
safe_env[env_var] = sandbox_env.from_string(tmpl).render(**safe_namespace)

if 'INVENTORY_UPDATE_ID' not in env:
# awx-manage inventory_update does not support extra_vars via -e
def build_extra_vars(node):
if isinstance(node, dict):
return {build_extra_vars(k): build_extra_vars(v) for k, v in node.items()}
elif isinstance(node, list):
return [build_extra_vars(x) for x in node]
else:
return sandbox_env.from_string(node).render(**namespace)

def build_extra_vars_file(vars, private_dir):
handle, path = tempfile.mkstemp(dir=os.path.join(private_dir, 'env'))
f = os.fdopen(handle, 'w')
f.write(safe_dump(vars))
f.close()
os.chmod(path, stat.S_IRUSR)
return path

extra_vars = build_extra_vars(self.injectors.get('extra_vars', {}))
if extra_vars:
path = build_extra_vars_file(extra_vars, private_data_dir)
container_path = get_incontainer_path(path, private_data_dir)
args.extend(['-e', '@%s' % container_path])
from awx_plugins.interfaces._temporary_private_inject_api import inject_credential

inject_credential(self, credential, env, safe_env, args, private_data_dir)

class ManagedCredentialType(SimpleNamespace):
registry = {}

def __init__(self, namespace, **kwargs):
for k in ('inputs', 'injectors'):
if k not in kwargs:
kwargs[k] = {}
kwargs.setdefault('custom_injectors', None)
super(ManagedCredentialType, self).__init__(namespace=namespace, **kwargs)
if namespace in ManagedCredentialType.registry:
raise ValueError(
'a ManagedCredentialType with namespace={} is already defined in {}'.format(
namespace, inspect.getsourcefile(ManagedCredentialType.registry[namespace].__class__)
)
)
ManagedCredentialType.registry[namespace] = self

def get_creation_params(self):
class CredentialTypeHelper:
@classmethod
def get_creation_params(cls, cred_type):
return dict(
namespace=self.namespace,
kind=self.kind,
name=self.name,
namespace=cred_type.namespace,
kind=cred_type.kind,
name=cred_type.name,
managed=True,
inputs=self.inputs,
injectors=self.injectors,
inputs=cred_type.inputs,
injectors=cred_type.injectors,
)

def create(self):
res = CredentialType(**self.get_creation_params())
res.custom_injectors = self.custom_injectors
@classmethod
def create(cls, cred_type):
res = CredentialType(**CredentialTypeHelper.get_creation_params(cred_type))
res.custom_injectors = cred_type.custom_injectors
return res

class ManagedCredentialType(SimpleNamespace):
registry = {}


class CredentialInputSource(PrimordialModel):
class Meta:
Expand Down Expand Up @@ -778,7 +643,26 @@ def get_absolute_url(self, request=None):
return reverse(view_name, kwargs={'pk': self.pk}, request=request)


from awx_plugins.credentials.plugins import * # noqa
awx_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials')}
supported_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials.supported')}
plugin_entry_points = awx_entry_points if detect_server_product_name() == 'AWX' else {**awx_entry_points, **supported_entry_points}

for ns, ep in plugin_entry_points.items():
cred_plugin = ep.load()
if not hasattr(cred_plugin, 'inputs'):
setattr(cred_plugin, 'inputs', {})
if not hasattr(cred_plugin, 'injectors'):
setattr(cred_plugin, 'injectors', {})
if ns in ManagedCredentialType.registry:
raise ValueError(
'a ManagedCredentialType with namespace={} is already defined in {}'.format(ns, inspect.getsourcefile(ManagedCredentialType.registry[ns].__class__))
)
ManagedCredentialType.registry[ns] = cred_plugin

credential_plugins = {ep.name: ep for ep in entry_points(group='awx_plugins.credentials')}
if detect_server_product_name() == 'AWX':
credential_plugins = {}

for ns, plugin in credential_plugins.items():
for ns, ep in credential_plugins.items():
plugin = ep.load()
CredentialType.load_plugin(ns, plugin)
Loading