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