1#!/usr/bin/python 2# -*- coding: utf-8 -*- 3# 4# (c) 2018, Ryan Conway (@rylon) 5# (c) 2018, Scott Buchanan <sbuchanan@ri.pn> (onepassword.py used as starting point) 6# (c) 2018, Ansible Project 7# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 8 9 10from __future__ import (absolute_import, division, print_function) 11__metaclass__ = type 12 13 14ANSIBLE_METADATA = {'metadata_version': '1.1', 15 'status': ['preview'], 16 'supported_by': 'community'} 17 18 19DOCUMENTATION = ''' 20module: onepassword_info 21author: 22 - Ryan Conway (@Rylon) 23version_added: "2.7" 24requirements: 25 - C(op) 1Password command line utility. See U(https://support.1password.com/command-line/) 26notes: 27 - Tested with C(op) version 0.5.5 28 - "Based on the C(onepassword) lookup plugin by Scott Buchanan <sbuchanan@ri.pn>." 29 - When this module is called with the deprecated C(onepassword_facts) name, potentially sensitive data 30 from 1Password is returned as Ansible facts. Facts are subject to caching if enabled, which means this 31 data could be stored in clear text on disk or in a database. 32short_description: Gather items from 1Password 33description: 34 - M(onepassword_info) wraps the C(op) command line utility to fetch data about one or more 1Password items. 35 - A fatal error occurs if any of the items being searched for can not be found. 36 - Recommend using with the C(no_log) option to avoid logging the values of the secrets being retrieved. 37 - This module was called C(onepassword_facts) before Ansible 2.9, returning C(ansible_facts). 38 Note that the M(onepassword_info) module no longer returns C(ansible_facts)! 39 You must now use the C(register) option to use the facts in other tasks. 40options: 41 search_terms: 42 type: list 43 description: 44 - A list of one or more search terms. 45 - Each search term can either be a simple string or it can be a dictionary for more control. 46 - When passing a simple string, I(field) is assumed to be C(password). 47 - When passing a dictionary, the following fields are available. 48 suboptions: 49 name: 50 type: str 51 description: 52 - The name of the 1Password item to search for (required). 53 field: 54 type: str 55 description: 56 - The name of the field to search for within this item (optional, defaults to "password" (or "document" if the item has an attachment). 57 section: 58 type: str 59 description: 60 - The name of a section within this item containing the specified field (optional, will search all sections if not specified). 61 vault: 62 type: str 63 description: 64 - The name of the particular 1Password vault to search, useful if your 1Password user has access to multiple vaults (optional). 65 required: True 66 auto_login: 67 type: dict 68 description: 69 - A dictionary containing authentication details. If this is set, M(onepassword_info) will attempt to sign in to 1Password automatically. 70 - Without this option, you must have already logged in via the 1Password CLI before running Ansible. 71 - It is B(highly) recommended to store 1Password credentials in an Ansible Vault. Ensure that the key used to encrypt 72 the Ansible Vault is equal to or greater in strength than the 1Password master password. 73 suboptions: 74 subdomain: 75 type: str 76 description: 77 - 1Password subdomain name (<subdomain>.1password.com). 78 - If this is not specified, the most recent subdomain will be used. 79 username: 80 type: str 81 description: 82 - 1Password username. 83 - Only required for initial sign in. 84 master_password: 85 type: str 86 description: 87 - The master password for your subdomain. 88 - This is always required when specifying C(auto_login). 89 required: True 90 secret_key: 91 type: str 92 description: 93 - The secret key for your subdomain. 94 - Only required for initial sign in. 95 default: {} 96 required: False 97 cli_path: 98 type: path 99 description: Used to specify the exact path to the C(op) command line interface 100 required: False 101 default: 'op' 102''' 103 104EXAMPLES = ''' 105# Gather secrets from 1Password, assuming there is a 'password' field: 106- name: Get a password 107 onepassword_info: 108 search_terms: My 1Password item 109 delegate_to: localhost 110 register: my_1password_item 111 no_log: true # Don't want to log the secrets to the console! 112 113# Gather secrets from 1Password, with more advanced search terms: 114- name: Get a password 115 onepassword_info: 116 search_terms: 117 - name: My 1Password item 118 field: Custom field name # optional, defaults to 'password' 119 section: Custom section name # optional, defaults to 'None' 120 vault: Name of the vault # optional, only necessary if there is more than 1 Vault available 121 delegate_to: localhost 122 register: my_1password_item 123 no_log: True # Don't want to log the secrets to the console! 124 125# Gather secrets combining simple and advanced search terms to retrieve two items, one of which we fetch two 126# fields. In the first 'password' is fetched, as a field name is not specified (default behaviour) and in the 127# second, 'Custom field name' is fetched, as that is specified explicitly. 128- name: Get a password 129 onepassword_info: 130 search_terms: 131 - My 1Password item # 'name' is optional when passing a simple string... 132 - name: My Other 1Password item # ...but it can also be set for consistency 133 - name: My 1Password item 134 field: Custom field name # optional, defaults to 'password' 135 section: Custom section name # optional, defaults to 'None' 136 vault: Name of the vault # optional, only necessary if there is more than 1 Vault available 137 - name: A 1Password item with document attachment 138 delegate_to: localhost 139 register: my_1password_item 140 no_log: true # Don't want to log the secrets to the console! 141 142- name: Debug a password (for example) 143 debug: 144 msg: "{{ my_1password_item['onepassword']['My 1Password item'] }}" 145''' 146 147RETURN = ''' 148--- 149# One or more dictionaries for each matching item from 1Password, along with the appropriate fields. 150# This shows the response you would expect to receive from the third example documented above. 151onepassword: 152 description: Dictionary of each 1password item matching the given search terms, shows what would be returned from the third example above. 153 returned: success 154 type: dict 155 sample: 156 "My 1Password item": 157 password: the value of this field 158 Custom field name: the value of this field 159 "My Other 1Password item": 160 password: the value of this field 161 "A 1Password item with document attachment": 162 document: the contents of the document attached to this item 163''' 164 165 166import errno 167import json 168import os 169import re 170 171from subprocess import Popen, PIPE 172 173from ansible.module_utils._text import to_bytes, to_native 174from ansible.module_utils.basic import AnsibleModule 175 176 177class AnsibleModuleError(Exception): 178 def __init__(self, results): 179 self.results = results 180 181 def __repr__(self): 182 return self.results 183 184 185class OnePasswordInfo(object): 186 187 def __init__(self): 188 self.cli_path = module.params.get('cli_path') 189 self.config_file_path = '~/.op/config' 190 self.auto_login = module.params.get('auto_login') 191 self.logged_in = False 192 self.token = None 193 194 terms = module.params.get('search_terms') 195 self.terms = self.parse_search_terms(terms) 196 197 def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False): 198 if self.token: 199 # Adds the session token to all commands if we're logged in. 200 args += [to_bytes('--session=') + self.token] 201 202 command = [self.cli_path] + args 203 p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE) 204 out, err = p.communicate(input=command_input) 205 rc = p.wait() 206 if not ignore_errors and rc != expected_rc: 207 raise AnsibleModuleError(to_native(err)) 208 return rc, out, err 209 210 def _parse_field(self, data_json, item_id, field_name, section_title=None): 211 data = json.loads(data_json) 212 213 if ('documentAttributes' in data['details']): 214 # This is actually a document, let's fetch the document data instead! 215 document = self._run(["get", "document", data['overview']['title']]) 216 return {'document': document[1].strip()} 217 218 else: 219 # This is not a document, let's try to find the requested field 220 221 # Some types of 1Password items have a 'password' field directly alongside the 'fields' attribute, 222 # not inside it, so we need to check there first. 223 if (field_name in data['details']): 224 return {field_name: data['details'][field_name]} 225 226 # Otherwise we continue looking inside the 'fields' attribute for the specified field. 227 else: 228 if section_title is None: 229 for field_data in data['details'].get('fields', []): 230 if field_data.get('name', '').lower() == field_name.lower(): 231 return {field_name: field_data.get('value', '')} 232 233 # Not found it yet, so now lets see if there are any sections defined 234 # and search through those for the field. If a section was given, we skip 235 # any non-matching sections, otherwise we search them all until we find the field. 236 for section_data in data['details'].get('sections', []): 237 if section_title is not None and section_title.lower() != section_data['title'].lower(): 238 continue 239 for field_data in section_data.get('fields', []): 240 if field_data.get('t', '').lower() == field_name.lower(): 241 return {field_name: field_data.get('v', '')} 242 243 # We will get here if the field could not be found in any section and the item wasn't a document to be downloaded. 244 optional_section_title = '' if section_title is None else " in the section '%s'" % section_title 245 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)) 246 247 def parse_search_terms(self, terms): 248 processed_terms = [] 249 250 for term in terms: 251 if not isinstance(term, dict): 252 term = {'name': term} 253 254 if 'name' not in term: 255 module.fail_json(msg="Missing required 'name' field from search term, got: '%s'" % to_native(term)) 256 257 term['field'] = term.get('field', 'password') 258 term['section'] = term.get('section', None) 259 term['vault'] = term.get('vault', None) 260 261 processed_terms.append(term) 262 263 return processed_terms 264 265 def get_raw(self, item_id, vault=None): 266 try: 267 args = ["get", "item", item_id] 268 if vault is not None: 269 args += ['--vault={0}'.format(vault)] 270 rc, output, dummy = self._run(args) 271 return output 272 273 except Exception as e: 274 if re.search(".*not found.*", to_native(e)): 275 module.fail_json(msg="Unable to find an item in 1Password named '%s'." % item_id) 276 else: 277 module.fail_json(msg="Unexpected error attempting to find an item in 1Password named '%s': %s" % (item_id, to_native(e))) 278 279 def get_field(self, item_id, field, section=None, vault=None): 280 output = self.get_raw(item_id, vault) 281 return self._parse_field(output, item_id, field, section) if output != '' else '' 282 283 def full_login(self): 284 if self.auto_login is not None: 285 if None in [self.auto_login.get('subdomain'), self.auto_login.get('username'), 286 self.auto_login.get('secret_key'), self.auto_login.get('master_password')]: 287 module.fail_json(msg='Unable to perform initial sign in to 1Password. ' 288 'subdomain, username, secret_key, and master_password are required to perform initial sign in.') 289 290 args = [ 291 'signin', 292 '{0}.1password.com'.format(self.auto_login['subdomain']), 293 to_bytes(self.auto_login['username']), 294 to_bytes(self.auto_login['secret_key']), 295 '--output=raw', 296 ] 297 298 try: 299 rc, out, err = self._run(args, command_input=to_bytes(self.auto_login['master_password'])) 300 self.token = out.strip() 301 except AnsibleModuleError as e: 302 module.fail_json(msg="Failed to perform initial sign in to 1Password: %s" % to_native(e)) 303 else: 304 module.fail_json(msg="Unable to perform an initial sign in to 1Password. Please run '%s sigin' " 305 "or define credentials in 'auto_login'. See the module documentation for details." % self.cli_path) 306 307 def get_token(self): 308 # If the config file exists, assume an initial signin has taken place and try basic sign in 309 if os.path.isfile(self.config_file_path): 310 311 if self.auto_login is not None: 312 313 # Since we are not currently signed in, master_password is required at a minimum 314 if not self.auto_login.get('master_password'): 315 module.fail_json(msg="Unable to sign in to 1Password. 'auto_login.master_password' is required.") 316 317 # Try signing in using the master_password and a subdomain if one is provided 318 try: 319 args = ['signin', '--output=raw'] 320 321 if self.auto_login.get('subdomain'): 322 args = ['signin', self.auto_login['subdomain'], '--output=raw'] 323 324 rc, out, err = self._run(args, command_input=to_bytes(self.auto_login['master_password'])) 325 self.token = out.strip() 326 327 except AnsibleModuleError: 328 self.full_login() 329 330 else: 331 self.full_login() 332 333 else: 334 # Attempt a full sign in since there appears to be no existing sign in 335 self.full_login() 336 337 def assert_logged_in(self): 338 try: 339 rc, out, err = self._run(['get', 'account'], ignore_errors=True) 340 if rc == 0: 341 self.logged_in = True 342 if not self.logged_in: 343 self.get_token() 344 except OSError as e: 345 if e.errno == errno.ENOENT: 346 module.fail_json(msg="1Password CLI tool '%s' not installed in path on control machine" % self.cli_path) 347 raise e 348 349 def run(self): 350 result = {} 351 352 self.assert_logged_in() 353 354 for term in self.terms: 355 value = self.get_field(term['name'], term['field'], term['section'], term['vault']) 356 357 if term['name'] in result: 358 # If we already have a result for this key, we have to append this result dictionary 359 # to the existing one. This is only applicable when there is a single item 360 # in 1Password which has two different fields, and we want to retrieve both of them. 361 result[term['name']].update(value) 362 else: 363 # If this is the first result for this key, simply set it. 364 result[term['name']] = value 365 366 return result 367 368 369def main(): 370 global module 371 module = AnsibleModule( 372 argument_spec=dict( 373 cli_path=dict(type='path', default='op'), 374 auto_login=dict(type='dict', options=dict( 375 subdomain=dict(type='str'), 376 username=dict(type='str'), 377 master_password=dict(required=True, type='str', no_log=True), 378 secret_key=dict(type='str', no_log=True), 379 ), default=None), 380 search_terms=dict(required=True, type='list') 381 ), 382 supports_check_mode=True 383 ) 384 385 results = {'onepassword': OnePasswordInfo().run()} 386 387 if module._name == 'onepassword_facts': 388 module.deprecate("The 'onepassword_facts' module has been renamed to 'onepassword_info'. " 389 "When called with the new name it no longer returns 'ansible_facts'", version='2.13') 390 module.exit_json(changed=False, ansible_facts=results) 391 else: 392 module.exit_json(changed=False, **results) 393 394 395if __name__ == '__main__': 396 main() 397