#!/usr/bin/python # -*- coding: utf-8 -*- # # (c) 2018, Ryan Conway (@rylon) # (c) 2018, Scott Buchanan (onepassword.py used as starting point) # (c) 2018, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import (absolute_import, division, print_function) __metaclass__ = type ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} DOCUMENTATION = ''' module: onepassword_facts author: - Ryan Conway (@rylon) version_added: "2.7" requirements: - C(op) 1Password command line utility (v0.5.5). See U(https://support.1password.com/command-line/) notes: - "Based on the C(onepassword) lookup plugin by Scott Buchanan ." short_description: Fetch facts from 1Password items description: - M(onepassword_facts) wraps the C(op) command line utility to fetch data about one or more 1password items and return as Ansible facts. - A fatal error occurs if any of the items being searched for can not be found. - Recommend using with the C(no_log) option to avoid logging the values of the secrets being retrieved. options: search_terms: description: - A list of one or more search terms. - Each search term can either be a simple string or it can be a dictionary for more control. - When passing a simple string, I(field) is assumed to be C(password). - When passing a dictionary, the following fields are available. suboptions: name: description: - The name of the 1Password item to search for (required). field: description: - The name of the field to search for within this item (optional, defaults to "password" (or "document" if the item has an attachment). section: description: - The name of a section within this item containing the specified field (optional, will search all sections if not specified). vault: description: - The name of the particular 1Password vault to search, useful if your 1Password user has access to multiple vaults (optional). required: True auto_login: description: - A dictionary containing authentication details. If this is set, M(onepassword_facts) will attempt to login to 1password automatically. - The required values can be stored in Ansible Vault, and passed to the module securely that way. - Without this option, you must have already logged in via the 1Password CLI before running Ansible. suboptions: account: description: - 1Password account name (.1password.com). username: description: - 1Password username. masterpassword: description: - The master password for your user. secretkey: description: - The secret key for your user. default: {} required: False cli_path: description: Used to specify the exact path to the C(op) command line interface required: False default: 'op' ''' EXAMPLES = ''' # Gather secrets from 1Password, assuming there is a 'password' field: - name: Get a password onepassword_facts: search_terms: My 1Password item delegate_to: local no_log: true # Don't want to log the secrets to the console! # Gather secrets from 1Password, with more advanced search terms: - name: Get a password onepassword_facts: search_terms: - name: My 1Password item field: Custom field name # optional, defaults to 'password' section: Custom section name # optional, defaults to 'None' vault: Name of the vault # optional, only necessary if there is more than 1 Vault available delegate_to: local no_log: true # Don't want to log the secrets to the console! # Gather secrets combining simple and advanced search terms to retrieve two items, one of which we fetch two # fields. In the first 'password' is fetched, as a field name is not specified (default behaviour) and in the # second, 'Custom field name' is fetched, as that is specified explicitly. - name: Get a password onepassword_facts: search_terms: - My 1Password item # 'name' is optional when passing a simple string... - name: My Other 1Password item # ...but it can also be set for consistency - name: My 1Password item field: Custom field name # optional, defaults to 'password' section: Custom section name # optional, defaults to 'None' vault: Name of the vault # optional, only necessary if there is more than 1 Vault available - name: A 1Password item with document attachment delegate_to: local no_log: true # Don't want to log the secrets to the console! ''' RETURN = ''' --- # One or more dictionaries for each matching item from 1Password, along with the appropriate fields. # This shows the response you would expect to receive from the third example documented above. onepassword: description: Dictionary of each 1password item matching the given search terms, shows what would be returned from the third example above. returned: success type: dict sample: "My 1Password item": password: the value of this field Custom field name: the value of this field "My Other 1Password item": password: the value of this field "A 1Password item with document attachment": document: the contents of the document attached to this item ''' import errno import json import os import re from subprocess import Popen, PIPE from ansible.module_utils._text import to_bytes, to_native from ansible.module_utils.basic import AnsibleModule class AnsibleModuleError(Exception): def __init__(self, results): self.results = results def __str__(self): return self.results def __repr__(self): return self.results class OnePasswordFacts(object): def __init__(self): self.cli_path = module.params.get('cli_path') self.auto_login = module.params.get('auto_login') self.token = None terms = module.params.get('search_terms') self.terms = self.parse_search_terms(terms) def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False): if self.token: # Adds the session token to all commands if we're logged in. args += [to_bytes('--session=') + self.token] command = [self.cli_path] + args p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE) out, err = p.communicate(input=command_input) rc = p.wait() if not ignore_errors and rc != expected_rc: raise AnsibleModuleError(to_native(err)) return rc, out, err def _parse_field(self, data_json, item_id, field_name, section_title=None): data = json.loads(data_json) if ('documentAttributes' in data['details']): # This is actually a document, let's fetch the document data instead! rc, output, error = self._run(["get", "document", data['overview']['title']]) return {'document': output.strip()} else: # This is not a document, let's try to find the requested field # Some types of 1Password items have a 'password' field directly alongside the 'fields' attribute, # not inside it, so we need to check there first. if (field_name in data['details']): return {field_name: data['details'][field_name]} # Otherwise we continue looking inside the 'fields' attribute for the specified field. else: if section_title is None: for field_data in data['details'].get('fields', []): if field_data.get('name').lower() == field_name.lower(): return {field_name: field_data.get('value', '')} # Not found it yet, so now lets see if there are any sections defined # and search through those for the field. If a section was given, we skip # any non-matching sections, otherwise we search them all until we find the field. for section_data in data['details'].get('sections', []): if section_title is not None and section_title.lower() != section_data['title'].lower(): continue for field_data in section_data.get('fields', []): if field_data.get('t').lower() == field_name.lower(): return {field_name: field_data.get('v', '')} # We will get here if the field could not be found in any section and the item wasn't a document to be downloaded. optional_section_title = '' if section_title is None else " in the section '%s'" % section_title module.fail_json(msg="Unable to find an item in 1Password named '%s' with the field '%s'%s." % (item_id, field_name, optional_section_title)) def parse_search_terms(self, terms): processed_terms = [] for term in terms: if not isinstance(term, dict): term = {'name': term} if 'name' not in term: module.fail_json(msg="Missing required 'name' field from search term, got: '%s'" % str(term)) term['field'] = term.get('field', 'password') term['section'] = term.get('section', None) term['vault'] = term.get('vault', None) processed_terms.append(term) return processed_terms def run(self): result = {} self.assert_logged_in() for term in self.terms: value = self.get_field(term['name'], term['field'], term['section'], term['vault']) if term['name'] in result: # If we already have a result for this key, we have to append this result dictionary # to the existing one. This is only applicable when there is a single item # in 1Password which has two different fields, and we want to retrieve both of them. result[term['name']].update(value) else: # If this is the first result for this key, simply set it. result[term['name']] = value return result def assert_logged_in(self): try: self._run(["get", "account"]) except OSError as e: if e.errno == errno.ENOENT: module.fail_json(msg="1Password CLI tool not installed in path '%s': %s" % (self.cli_path, e)) else: module.fail_json(msg="1Password CLI tool failed to execute at path '%s': %s" % (self.cli_path, e)) except Exception as e: # 1Password's CLI doesn't seem to return different error codes, so we need to handle a few of the common # error cases by searching via regex, so we can provide a clearer error message to the user. if re.search(".*You are not currently signed in.*", str(e)) is not None: if (self.auto_login is not None): try: rc, out, err = self._run([ "signin", "%s.1password.com" % self.auto_login['account'], self.auto_login['username'], self.auto_login['secretkey'], "--shorthand=ansible_%s" % self.auto_login['account'], "--output=raw" ], command_input=self.auto_login['masterpassword']) self.token = out.strip() except Exception as e: module.fail_json(msg="Unable to automatically login to 1Password: %s " % e) else: module.fail_json(msg=( "Not logged into 1Password: please run '%s signin' first, or see the module docs for " "how to configure for automatic login." % self.cli_path) ) def get_raw(self, item_id, vault=None): try: args = ["get", "item", item_id] if vault is not None: args += ['--vault={0}'.format(vault)] rc, output, err = self._run(args) return output except Exception as e: if re.search(".*not found.*", str(e)): module.fail_json(msg="Unable to find an item in 1Password named '%s'." % item_id) else: module.fail_json(msg="Unexpected error attempting to find an item in 1Password named '%s': %s" % (item_id, e)) def get_field(self, item_id, field, section=None, vault=None): output = self.get_raw(item_id, vault) return self._parse_field(output, item_id, field, section) if output != '' else '' def main(): global module module = AnsibleModule( argument_spec=dict( cli_path=dict(type='path', default='op'), auto_login=dict(type='dict', options=dict( account=dict(required=True, type='str'), username=dict(required=True, type='str'), masterpassword=dict(required=True, type='str'), secretkey=dict(required=True, type='str'), ), default=None), search_terms=dict(required=True, type='list') ), supports_check_mode=True ) ansible_facts = {'onepassword': OnePasswordFacts().run()} module_return = dict(changed=False, ansible_facts=ansible_facts) module.exit_json(**module_return) if __name__ == '__main__': main()