class CredentialType(CommonModelNameNotUnique): ''' A reusable schema for a credential. Used to define a named credential type with fields (e.g., an API key) and output injectors (i.e., an environment variable that uses the API key). ''' 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', 'AWX_HOST', 'MAX_EVENT_RES', 'CALLBACK_QUEUE', 'CALLBACK_CONNECTION', 'CACHE', 'JOB_CALLBACK_DEBUG', 'INVENTORY_HOSTVARS', 'FACT_QUEUE', )) class Meta: app_label = 'main' ordering = ('kind', 'name') unique_together = (('name', 'kind'), ) KIND_CHOICES = ( ('ssh', _('Machine')), ('vault', _('Vault')), ('net', _('Network')), ('scm', _('Source Control')), ('cloud', _('Cloud')), ('insights', _('Insights')), ) kind = models.CharField(max_length=32, choices=KIND_CHOICES) managed_by_tower = models.BooleanField(default=False, editable=False) inputs = CredentialTypeInputField( blank=True, default={}, help_text=_('Enter inputs using either JSON or YAML syntax. Use the ' 'radio button to toggle between the two. Refer to the ' 'Ansible Tower documentation for example syntax.')) injectors = CredentialTypeInjectorField( blank=True, default={}, help_text=_( 'Enter injectors using either JSON or YAML syntax. Use the ' 'radio button to toggle between the two. Refer to the ' 'Ansible Tower documentation for example syntax.')) def get_absolute_url(self, request=None): return reverse('api:credential_type_detail', kwargs={'pk': self.pk}, request=request) @property def unique_by_kind(self): return self.kind != 'cloud' @property def defined_fields(self): return [field.get('id') for field in self.inputs.get('fields', [])] @property def secret_fields(self): return [ field['id'] for field in self.inputs.get('fields', []) if field.get('secret', False) is True ] @property def askable_fields(self): return [ field['id'] for field in self.inputs.get('fields', []) if field.get('ask_at_runtime', False) is True ] def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: if 'choices' in field: return field['choices'][0] return {'string': '', 'boolean': False}[field['type']] @classmethod def default(cls, f): func = functools.partial(f, cls) cls.defaults[f.__name__] = func return func @classmethod def setup_tower_managed_defaults(cls, persisted=True): for default in cls.defaults.values(): default_ = default() if persisted: default_.save() @classmethod def from_v1_kind(cls, kind, data={}): match = None kind = kind or 'ssh' kind_choices = dict(V1Credential.KIND_CHOICES) requirements = {} if kind == 'ssh': if data.get('vault_password'): requirements['kind'] = 'vault' else: requirements['kind'] = 'ssh' elif kind in ('net', 'scm', 'insights'): requirements['kind'] = kind elif kind in kind_choices: requirements.update(dict(kind='cloud', name=kind_choices[kind])) if requirements: requirements['managed_by_tower'] = True match = cls.objects.filter(**requirements)[:1].get() 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 type(value) is bool: # boolean values can't be secret/encrypted safe_namespace[field_name] = namespace[field_name] = value continue 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)])
class CredentialType(CommonModelNameNotUnique): """ A reusable schema for a credential. Used to define a named credential type with fields (e.g., an API key) and output injectors (i.e., an environment variable that uses the API key). """ class Meta: app_label = 'main' ordering = ('kind', 'name') unique_together = (('name', 'kind'),) KIND_CHOICES = ( ('ssh', _('Machine')), ('vault', _('Vault')), ('net', _('Network')), ('scm', _('Source Control')), ('cloud', _('Cloud')), ('registry', _('Container Registry')), ('token', _('Personal Access Token')), ('insights', _('Insights')), ('external', _('External')), ('kubernetes', _('Kubernetes')), ('galaxy', _('Galaxy/Automation Hub')), ) kind = models.CharField(max_length=32, choices=KIND_CHOICES) managed_by_tower = models.BooleanField(default=False, editable=False) namespace = models.CharField(max_length=1024, null=True, default=None, editable=False) inputs = CredentialTypeInputField( blank=True, default=dict, help_text=_('Enter inputs using either JSON or YAML syntax. ' 'Refer to the Ansible Tower documentation for example syntax.') ) injectors = CredentialTypeInjectorField( blank=True, default=dict, help_text=_('Enter injectors using either JSON or YAML syntax. ' 'Refer to the Ansible Tower documentation for example syntax.'), ) @classmethod def from_db(cls, db, field_names, values): instance = super(CredentialType, cls).from_db(db, field_names, values) if instance.managed_by_tower and instance.namespace: native = ManagedCredentialType.registry[instance.namespace] instance.inputs = native.inputs instance.injectors = native.injectors return instance def get_absolute_url(self, request=None): return reverse('api:credential_type_detail', kwargs={'pk': self.pk}, request=request) @property def defined_fields(self): return [field.get('id') for field in self.inputs.get('fields', [])] @property def secret_fields(self): return [field['id'] for field in self.inputs.get('fields', []) if field.get('secret', False) is True] @property def askable_fields(self): return [field['id'] for field in self.inputs.get('fields', []) if field.get('ask_at_runtime', False) is True] @property def plugin(self): if self.kind != 'external': raise AttributeError('plugin') [plugin] = [plugin for ns, plugin in credential_plugins.items() if ns == self.namespace] return plugin def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: if 'choices' in field: return field['choices'][0] return {'string': '', 'boolean': False}[field['type']] @classproperty def defaults(cls): return dict((k, functools.partial(v.create)) for k, v in ManagedCredentialType.registry.items()) @classmethod def setup_tower_managed_defaults(cls): for default in ManagedCredentialType.registry.values(): existing = CredentialType.objects.filter(name=default.name, kind=default.kind).first() if existing is not None: existing.namespace = default.namespace existing.inputs = {} existing.injectors = {} existing.save() continue logger.debug(_("adding %s credential type" % default.name)) created = default.create() created.inputs = created.injectors = {} created.save() @classmethod def load_plugin(cls, ns, plugin): ManagedCredentialType(namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs) 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_by_tower and credential.credential_type.namespace in dir(builtin_injectors): injected_env = {} getattr(builtin_injectors, credential.credential_type.namespace)(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) injectable_fields = list(credential.inputs.keys()) + credential.dynamic_input_fields for field_name in list(set(injectable_fields)): 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=private_data_dir) with open(path, 'w') as f: f.write(data) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # FIXME: develop some better means of referencing paths inside containers container_path = os.path.join('/runner', os.path.basename(path)) # 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 extra_vars = {} for var_name, tmpl in self.injectors.get('extra_vars', {}).items(): extra_vars[var_name] = sandbox_env.from_string(tmpl).render(**namespace) def build_extra_vars_file(vars, private_dir): handle, path = tempfile.mkstemp(dir=private_dir) f = os.fdopen(handle, 'w') f.write(safe_dump(vars)) f.close() os.chmod(path, stat.S_IRUSR) return path if extra_vars: path = build_extra_vars_file(extra_vars, private_data_dir) # FIXME: develop some better means of referencing paths inside containers container_path = os.path.join('/runner', os.path.basename(path)) args.extend(['-e', '@%s' % container_path])
class CredentialType(CommonModelNameNotUnique): ''' A reusable schema for a credential. Used to define a named credential type with fields (e.g., an API key) and output injectors (i.e., an environment variable that uses the API key). ''' defaults = OrderedDict() class Meta: app_label = 'main' ordering = ('kind', 'name') unique_together = (('name', 'kind'), ) KIND_CHOICES = ( ('ssh', _('Machine')), ('vault', _('Vault')), ('net', _('Network')), ('scm', _('Source Control')), ('cloud', _('Cloud')), ('insights', _('Insights')), ) kind = models.CharField(max_length=32, choices=KIND_CHOICES) managed_by_tower = models.BooleanField(default=False, editable=False) inputs = CredentialTypeInputField( blank=True, default={}, help_text=_('Enter inputs using either JSON or YAML syntax. Use the ' 'radio button to toggle between the two. Refer to the ' 'Ansible Tower documentation for example syntax.')) injectors = CredentialTypeInjectorField( blank=True, default={}, help_text=_( 'Enter injectors using either JSON or YAML syntax. Use the ' 'radio button to toggle between the two. Refer to the ' 'Ansible Tower documentation for example syntax.')) def get_absolute_url(self, request=None): return reverse('api:credential_type_detail', kwargs={'pk': self.pk}, request=request) @property def unique_by_kind(self): return self.kind != 'cloud' @property def defined_fields(self): return [field.get('id') for field in self.inputs.get('fields', [])] @property def secret_fields(self): return [ field['id'] for field in self.inputs.get('fields', []) if field.get('secret', False) is True ] @property def askable_fields(self): return [ field['id'] for field in self.inputs.get('fields', []) if field.get('ask_at_runtime', False) is True ] def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: if 'choices' in field: return field['choices'][0] return { 'string': '', 'boolean': False, 'become_method': '' }[field['type']] @classmethod def default(cls, f): func = functools.partial(f, cls) cls.defaults[f.__name__] = func return func @classmethod def setup_tower_managed_defaults(cls, persisted=True): for default in cls.defaults.values(): default_ = default() if persisted: if CredentialType.objects.filter(name=default_.name, kind=default_.kind).count(): continue logger.debug(_("adding %s credential type" % default_.name)) default_.save() @classmethod def from_v1_kind(cls, kind, data={}): match = None kind = kind or 'ssh' kind_choices = dict(V1Credential.KIND_CHOICES) requirements = {} if kind == 'ssh': if data.get('vault_password'): requirements['kind'] = 'vault' else: requirements['kind'] = 'ssh' elif kind in ('net', 'scm', 'insights'): requirements['kind'] = kind elif kind in kind_choices: requirements.update(dict(kind='cloud', name=kind_choices[kind])) if requirements: requirements['managed_by_tower'] = True match = cls.objects.filter(**requirements)[:1].get() 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: if self.managed_by_tower and credential.kind in dir( builtin_injectors): injected_env = {} getattr(builtin_injectors, credential.kind)(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 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 type(value) is bool: # boolean values can't be secret/encrypted safe_namespace[field_name] = namespace[field_name] = value continue 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 # default missing boolean fields to False for field in self.inputs.get('fields', []): if field['type'] == 'boolean' and field[ 'id'] not in credential.inputs.keys(): namespace[field['id']] = safe_namespace[field['id']] = False 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 for file_label, file_tmpl in file_tmpls.items(): data = Template(file_tmpl).render(**namespace) _, path = tempfile.mkstemp(dir=private_data_dir) with open(path, 'w') as f: f.write(data.encode('utf-8')) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # determine if filename indicates single file or many if file_label.find('.') == -1: tower_namespace.filename = path else: if not hasattr(tower_namespace, 'filename'): tower_namespace.filename = TowerNamespace() file_label = file_label.split('.')[1] setattr(tower_namespace.filename, file_label, 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( six.text_type( 'Ignoring prohibited env var {}, reason: {}').format( env_var, e)) continue env[env_var] = Template(tmpl).render(**namespace) safe_env[env_var] = Template(tmpl).render(**safe_namespace) if 'INVENTORY_UPDATE_ID' not in env: # awx-manage inventory_update does not support extra_vars via -e extra_vars = {} for var_name, tmpl in self.injectors.get('extra_vars', {}).items(): extra_vars[var_name] = Template(tmpl).render(**namespace) def build_extra_vars_file(vars, private_dir): handle, path = tempfile.mkstemp(dir=private_dir) f = os.fdopen(handle, 'w') f.write(safe_dump(vars)) f.close() os.chmod(path, stat.S_IRUSR) return path path = build_extra_vars_file(extra_vars, private_data_dir) if extra_vars: args.extend(['-e', '@%s' % path]) safe_args.extend(['-e', '@%s' % path])