mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
Unify terms and UI between 1Password lookups and facts module (#45427)
* Unify login behavior between 1Password lookup plugins and module - Use the same names for all credential aspects - Only require the minimal amount of information for each - Add more examples * Change parameter terms - use terms in line with 1Password documentation. - update examples - update tests * Improve error messages in lookup plugin * Unify onepassword_facts with lookup plugins - use same methods and logic for signing in or reusing existing session - unify terms with lookup plugins * Change rc test for determing login An rc other than 1 can be returned when a current login session does not exist. * Create AnsibleModuleError class ansible.errors is not available to modules, so create an AnsibleModuleError class within the module Do not user os.path.expanduser since this is already done by virtue of the type being "path" in the argument spec. * Add note about risk with fact caching sensitive data * Add note on op version that was used for testing
This commit is contained in:
parent
45b5685037
commit
b60854357b
4 changed files with 319 additions and 185 deletions
|
@ -22,12 +22,16 @@ author:
|
||||||
- Ryan Conway (@rylon)
|
- Ryan Conway (@rylon)
|
||||||
version_added: "2.7"
|
version_added: "2.7"
|
||||||
requirements:
|
requirements:
|
||||||
- C(op) 1Password command line utility (v0.5.1). See U(https://support.1password.com/command-line/)
|
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
|
||||||
notes:
|
notes:
|
||||||
- "Based on the C(onepassword) lookup plugin by Scott Buchanan <sbuchanan@ri.pn>."
|
- "Based on the C(onepassword) lookup plugin by Scott Buchanan <sbuchanan@ri.pn>."
|
||||||
short_description: Fetch facts from 1Password items
|
- This module stores potentially sensitive data from 1Password as Ansible facts.
|
||||||
|
Facts are subject to caching if enabled, which means this data could be stored in clear text
|
||||||
|
on disk or in a database.
|
||||||
|
- Tested with C(op) version 0.5.3
|
||||||
|
short_description: Gather items from 1Password and set them as facts
|
||||||
description:
|
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.
|
- 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.
|
- 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.
|
- Recommend using with the C(no_log) option to avoid logging the values of the secrets being retrieved.
|
||||||
options:
|
options:
|
||||||
|
@ -53,22 +57,28 @@ options:
|
||||||
required: True
|
required: True
|
||||||
auto_login:
|
auto_login:
|
||||||
description:
|
description:
|
||||||
- A dictionary containing authentication details. If this is set, M(onepassword_facts) will attempt to login to 1password automatically.
|
- A dictionary containing authentication details. If this is set, M(onepassword_facts) will attempt to sign in 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.
|
- Without this option, you must have already logged in via the 1Password CLI before running Ansible.
|
||||||
|
- It is B(highly) recommened to store 1Password credentials in an Ansible Vault. Ensure that the key used to encrypt
|
||||||
|
the Ansible Vault is equal to or greater in strength than the 1Password master password.
|
||||||
suboptions:
|
suboptions:
|
||||||
account:
|
subdomain:
|
||||||
description:
|
description:
|
||||||
- 1Password account name (<account>.1password.com).
|
- 1Password subdomain name (<subdomain>.1password.com).
|
||||||
|
- If this is not specified, the most recent subdomain will be used.
|
||||||
username:
|
username:
|
||||||
description:
|
description:
|
||||||
- 1Password username.
|
- 1Password username.
|
||||||
masterpassword:
|
- Only required for initial sign in.
|
||||||
|
master_password:
|
||||||
description:
|
description:
|
||||||
- The master password for your user.
|
- The master password for your subdomain.
|
||||||
secretkey:
|
- This is always required when specifying C(auto_login).
|
||||||
|
required: True
|
||||||
|
secret_key:
|
||||||
description:
|
description:
|
||||||
- The secret key for your user.
|
- The secret key for your subdomain.
|
||||||
|
- Only required for initial sign in.
|
||||||
default: {}
|
default: {}
|
||||||
required: False
|
required: False
|
||||||
cli_path:
|
cli_path:
|
||||||
|
@ -82,8 +92,8 @@ EXAMPLES = '''
|
||||||
- name: Get a password
|
- name: Get a password
|
||||||
onepassword_facts:
|
onepassword_facts:
|
||||||
search_terms: My 1Password item
|
search_terms: My 1Password item
|
||||||
delegate_to: local
|
delegate_to: localhost
|
||||||
no_log: true # Don't want to log the secrets to the console!
|
no_log: true # Don't want to log the secrets to the console!
|
||||||
|
|
||||||
# Gather secrets from 1Password, with more advanced search terms:
|
# Gather secrets from 1Password, with more advanced search terms:
|
||||||
- name: Get a password
|
- name: Get a password
|
||||||
|
@ -93,8 +103,8 @@ EXAMPLES = '''
|
||||||
field: Custom field name # optional, defaults to 'password'
|
field: Custom field name # optional, defaults to 'password'
|
||||||
section: Custom section name # optional, defaults to 'None'
|
section: Custom section name # optional, defaults to 'None'
|
||||||
vault: Name of the vault # optional, only necessary if there is more than 1 Vault available
|
vault: Name of the vault # optional, only necessary if there is more than 1 Vault available
|
||||||
delegate_to: local
|
delegate_to: localhost
|
||||||
no_log: true # Don't want to log the secrets to the console!
|
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
|
# 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
|
# fields. In the first 'password' is fetched, as a field name is not specified (default behaviour) and in the
|
||||||
|
@ -109,8 +119,8 @@ EXAMPLES = '''
|
||||||
section: Custom section name # optional, defaults to 'None'
|
section: Custom section name # optional, defaults to 'None'
|
||||||
vault: Name of the vault # optional, only necessary if there is more than 1 Vault available
|
vault: Name of the vault # optional, only necessary if there is more than 1 Vault available
|
||||||
- name: A 1Password item with document attachment
|
- name: A 1Password item with document attachment
|
||||||
delegate_to: local
|
delegate_to: localhost
|
||||||
no_log: true # Don't want to log the secrets to the console!
|
no_log: true # Don't want to log the secrets to the console!
|
||||||
'''
|
'''
|
||||||
|
|
||||||
RETURN = '''
|
RETURN = '''
|
||||||
|
@ -136,126 +146,41 @@ import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from subprocess import Popen, PIPE
|
from subprocess import Popen, PIPE
|
||||||
|
|
||||||
|
from ansible.module_utils._text import to_bytes, to_native
|
||||||
from ansible.module_utils.basic import AnsibleModule
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
|
||||||
|
class AnsibleModuleError(Exception):
|
||||||
|
def __init__(self, results):
|
||||||
|
self.results = results
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
|
||||||
class OnePasswordFacts(object):
|
class OnePasswordFacts(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cli_path = module.params.get('cli_path')
|
self.cli_path = module.params.get('cli_path')
|
||||||
|
self.config_file_path = '~/.op/config'
|
||||||
self.auto_login = module.params.get('auto_login')
|
self.auto_login = module.params.get('auto_login')
|
||||||
self.token = {}
|
self.logged_in = False
|
||||||
|
self.token = None
|
||||||
|
|
||||||
terms = module.params.get('search_terms')
|
terms = module.params.get('search_terms')
|
||||||
self.terms = self.parse_search_terms(module.params.get('search_terms'))
|
self.terms = self.parse_search_terms(terms)
|
||||||
|
|
||||||
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:
|
|
||||||
token = self._run([
|
|
||||||
"signin", "%s.1password.com" % self.auto_login['account'],
|
|
||||||
self.auto_login['username'],
|
|
||||||
self.auto_login['secretkey'],
|
|
||||||
self.auto_login['masterpassword'],
|
|
||||||
"--shorthand=ansible_%s" % self.auto_login['account'],
|
|
||||||
"--output=raw"
|
|
||||||
])
|
|
||||||
self.token = {'OP_SESSION_ansible_%s' % self.auto_login['account']: token[0].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)]
|
|
||||||
output, dummy = 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 _run(self, args, expected_rc=0):
|
|
||||||
# Duplicates the current shell environment before running 'op', so we get the same PATH the user has,
|
|
||||||
# but we merge in the auth token dictionary, allowing the auto-login functionality to work (if enabled).
|
|
||||||
env = {}
|
|
||||||
env.update(os.environ.copy())
|
|
||||||
env.update(self.token)
|
|
||||||
|
|
||||||
p = Popen([self.cli_path] + args, stdout=PIPE, stderr=PIPE, stdin=PIPE, env=env)
|
|
||||||
out, err = p.communicate()
|
|
||||||
|
|
||||||
|
def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False):
|
||||||
|
command = [self.cli_path] + args
|
||||||
|
p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
|
||||||
|
out, err = p.communicate(input=command_input)
|
||||||
rc = p.wait()
|
rc = p.wait()
|
||||||
|
if not ignore_errors and rc != expected_rc:
|
||||||
if rc != expected_rc:
|
raise AnsibleModuleError(to_native(err))
|
||||||
raise Exception(err)
|
return rc, out, err
|
||||||
|
|
||||||
return out, err
|
|
||||||
|
|
||||||
def _parse_field(self, data_json, item_id, field_name, section_title=None):
|
def _parse_field(self, data_json, item_id, field_name, section_title=None):
|
||||||
data = json.loads(data_json)
|
data = json.loads(data_json)
|
||||||
|
@ -286,6 +211,129 @@ class OnePasswordFacts(object):
|
||||||
optional_section_title = '' if section_title is None else " in the section '%s'" % section_title
|
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))
|
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'" % to_native(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 get_raw(self, item_id, vault=None):
|
||||||
|
try:
|
||||||
|
args = ["get", "item", item_id]
|
||||||
|
if vault is not None:
|
||||||
|
args += ['--vault={0}'.format(vault)]
|
||||||
|
if not self.logged_in:
|
||||||
|
args += [to_bytes('--session=') + self.token]
|
||||||
|
rc, output, dummy = self._run(args)
|
||||||
|
return output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if re.search(".*not found.*", to_native(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, to_native(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 full_login(self):
|
||||||
|
if self.auto_login is not None:
|
||||||
|
if None in [self.auto_login.get('subdomain'), self.auto_login.get('username'),
|
||||||
|
self.auto_login.get('secret_key'), self.auto_login.get('master_password')]:
|
||||||
|
module.fail_json(msg='Unable to perform initial sign in to 1Password. '
|
||||||
|
'subdomain, username, secret_key, and master_password are required to perform initial sign in.')
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'signin',
|
||||||
|
'{0}.1password.com'.format(self.auto_login['subdomain']),
|
||||||
|
to_bytes(self.auto_login['username']),
|
||||||
|
to_bytes(self.auto_login['secret_key']),
|
||||||
|
'--output=raw',
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
rc, out, err = self._run(args, command_input=to_bytes(self.auto_login['master_password']))
|
||||||
|
self.token = out.strip()
|
||||||
|
except AnsibleModuleError as e:
|
||||||
|
module.fail_json(msg="Failed to perform initial sign in to 1Password: %s" % to_native(e))
|
||||||
|
else:
|
||||||
|
module.fail_json(msg="Unable to perform an initial sign in to 1Password. Please run '%s sigin' "
|
||||||
|
"or define credentials in 'auto_login'. See the module documentation for details." % self.cli_path)
|
||||||
|
|
||||||
|
def get_token(self):
|
||||||
|
# If the config file exists, assume an initial signin has taken place and try basic sign in
|
||||||
|
if os.path.isfile(self.config_file_path):
|
||||||
|
|
||||||
|
if self.auto_login is not None:
|
||||||
|
|
||||||
|
# Since we are not currently signed in, master_password is required at a minimum
|
||||||
|
if not self.auto_login.get('master_password'):
|
||||||
|
module.fail_json(msg="Unable to sign in to 1Password. 'auto_login.master_password' is required.")
|
||||||
|
|
||||||
|
# Try signing in using the master_password and a subdomain if one is provided
|
||||||
|
try:
|
||||||
|
args = ['signin', '--output=raw']
|
||||||
|
|
||||||
|
if self.auto_login.get('subdomain'):
|
||||||
|
args = ['signin', self.auto_login['subdomain'], '--output=raw']
|
||||||
|
|
||||||
|
rc, out, err = self._run(args, command_input=to_bytes(self.auto_login['master_password']))
|
||||||
|
self.token = out.strip()
|
||||||
|
|
||||||
|
except AnsibleModuleError:
|
||||||
|
self.full_login()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.full_login()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Attempt a full sign in since there appears to be no existing sign in
|
||||||
|
self.full_login()
|
||||||
|
|
||||||
|
def assert_logged_in(self):
|
||||||
|
try:
|
||||||
|
rc, out, err = self._run(['get', 'account'], ignore_errors=True)
|
||||||
|
if rc == 0:
|
||||||
|
self.logged_in = True
|
||||||
|
if not self.logged_in:
|
||||||
|
self.get_token()
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOENT:
|
||||||
|
module.fail_json(msg="1Password CLI tool '%s' not installed in path on control machine" % self.cli_path)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
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 main():
|
def main():
|
||||||
global module
|
global module
|
||||||
|
@ -293,10 +341,10 @@ def main():
|
||||||
argument_spec=dict(
|
argument_spec=dict(
|
||||||
cli_path=dict(type='path', default='op'),
|
cli_path=dict(type='path', default='op'),
|
||||||
auto_login=dict(type='dict', options=dict(
|
auto_login=dict(type='dict', options=dict(
|
||||||
account=dict(required=True, type='str'),
|
subdomain=dict(type='str'),
|
||||||
username=dict(required=True, type='str'),
|
username=dict(type='str'),
|
||||||
masterpassword=dict(required=True, type='str'),
|
master_password=dict(required=True, type='str', no_log=True),
|
||||||
secretkey=dict(required=True, type='str'),
|
secret_key=dict(type='str', no_log=True),
|
||||||
), default=None),
|
), default=None),
|
||||||
search_terms=dict(required=True, type='list')
|
search_terms=dict(required=True, type='list')
|
||||||
),
|
),
|
||||||
|
|
|
@ -20,10 +20,9 @@ DOCUMENTATION = """
|
||||||
version_added: "2.6"
|
version_added: "2.6"
|
||||||
requirements:
|
requirements:
|
||||||
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
|
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
|
||||||
- must have already logged into 1Password using C(op) CLI
|
|
||||||
short_description: fetch field values from 1Password
|
short_description: fetch field values from 1Password
|
||||||
description:
|
description:
|
||||||
- onepassword wraps the C(op) command line utility to fetch specific field values from 1Password
|
- C(onepassword) wraps the C(op) command line utility to fetch specific field values from 1Password.
|
||||||
options:
|
options:
|
||||||
_terms:
|
_terms:
|
||||||
description: identifier(s) (UUID, name, or subdomain; case-insensitive) of item(s) to retrieve
|
description: identifier(s) (UUID, name, or subdomain; case-insensitive) of item(s) to retrieve
|
||||||
|
@ -31,38 +30,70 @@ DOCUMENTATION = """
|
||||||
field:
|
field:
|
||||||
description: field to return from each matching item (case-insensitive)
|
description: field to return from each matching item (case-insensitive)
|
||||||
default: 'password'
|
default: 'password'
|
||||||
|
master_password:
|
||||||
|
description: The password used to unlock the specified vault.
|
||||||
|
default: None
|
||||||
|
version_added: '2.7'
|
||||||
|
aliases: ['vault_password']
|
||||||
section:
|
section:
|
||||||
description: item section containing the field to retrieve (case-insensitive); if absent will return first match from any section
|
description: Item section containing the field to retrieve (case-insensitive). If absent will return first match from any section.
|
||||||
default: None
|
default: None
|
||||||
subdomain:
|
subdomain:
|
||||||
description: The 1Password subdomain to authenticate against.
|
description: The 1Password subdomain to authenticate against.
|
||||||
default: None
|
default: None
|
||||||
version_added: '2.7'
|
version_added: '2.7'
|
||||||
vault:
|
username:
|
||||||
description: vault containing the item to retrieve (case-insensitive); if absent will search all vaults
|
description: The username used to sign in.
|
||||||
default: None
|
|
||||||
vault_password:
|
|
||||||
description: The password used to unlock the specified vault.
|
|
||||||
default: None
|
|
||||||
version_added: '2.7'
|
version_added: '2.7'
|
||||||
|
secret_key:
|
||||||
|
description: The secret key used when performing an initial sign in.
|
||||||
|
version_added: '2.7'
|
||||||
|
vault:
|
||||||
|
description: Vault containing the item to retrieve (case-insensitive). If absent will search all vaults
|
||||||
|
default: None
|
||||||
|
notes:
|
||||||
|
- This lookup will use an existing 1Password session if one exists. If not, and you have already
|
||||||
|
performed an initial sign in (meaning C(~/.op/config exists)), then only the C(master_password) is required.
|
||||||
|
You may optionally specify C(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op).
|
||||||
|
- This lookup can perform an initial login by providing C(subdomain), C(username), C(secret_key), and C(master_password).
|
||||||
|
- Due to the B(very) sensitive nature of these credentials, it is B(highly) recommeneded that you only pass in the minial credentials
|
||||||
|
needed at any given time. Also, store these credentials in an Ansible Vault using a key that is equal to or greater in strength
|
||||||
|
to the 1Password master password.
|
||||||
|
- This lookup stores potentially sensitive data from 1Password as Ansible facts.
|
||||||
|
Facts are subject to caching if enabled, which means this data could be stored in clear text
|
||||||
|
on disk or in a database.
|
||||||
|
- Tested with C(op) version 0.5.3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EXAMPLES = """
|
EXAMPLES = """
|
||||||
- name: Retrieve password for KITT
|
# These examples only work when already signed in to 1Password
|
||||||
|
- name: Retrieve password for KITT when already signed in to 1Password
|
||||||
debug:
|
debug:
|
||||||
var: lookup('onepassword', 'KITT')
|
var: lookup('onepassword', 'KITT')
|
||||||
|
|
||||||
- name: Retrieve password for Wintermute
|
- name: Retrieve password for Wintermute when already signed in to 1Password
|
||||||
debug:
|
debug:
|
||||||
var: lookup('onepassword', 'Tessier-Ashpool', section='Wintermute')
|
var: lookup('onepassword', 'Tessier-Ashpool', section='Wintermute')
|
||||||
|
|
||||||
- name: Retrieve username for HAL
|
- name: Retrieve username for HAL when already signed in to 1Password
|
||||||
debug:
|
debug:
|
||||||
var: lookup('onepassword', 'HAL 9000', field='username', vault='Discovery')
|
var: lookup('onepassword', 'HAL 9000', field='username', vault='Discovery')
|
||||||
|
|
||||||
- name: Retrieve password for HAL when not signed in to 1Password
|
- name: Retrieve password for HAL when not signed in to 1Password
|
||||||
debug:
|
debug:
|
||||||
var: lookup('onepassword', 'HAL 9000', subdomain='Discovery', vault_password='DmbslfLvasjdl')
|
var: lookup('onepassword'
|
||||||
|
'HAL 9000'
|
||||||
|
subdomain='Discovery'
|
||||||
|
master_password=vault_master_password)
|
||||||
|
|
||||||
|
- name: Retrieve password for HAL when never signed in to 1Password
|
||||||
|
debug:
|
||||||
|
var: lookup('onepassword'
|
||||||
|
'HAL 9000'
|
||||||
|
subdomain='Discovery'
|
||||||
|
master_password=vault_master_password
|
||||||
|
username='tweety@acme.com'
|
||||||
|
secret_key=vault_secret_key)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RETURN = """
|
RETURN = """
|
||||||
|
@ -70,56 +101,70 @@ RETURN = """
|
||||||
description: field data requested
|
description: field data requested
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import errno
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
from subprocess import Popen, PIPE
|
from subprocess import Popen, PIPE
|
||||||
|
|
||||||
from ansible.plugins.lookup import LookupBase
|
from ansible.plugins.lookup import LookupBase
|
||||||
from ansible.errors import AnsibleLookupError
|
from ansible.errors import AnsibleLookupError
|
||||||
from ansible.module_utils._text import to_bytes
|
from ansible.module_utils._text import to_bytes, to_text
|
||||||
|
|
||||||
|
|
||||||
class OnePass(object):
|
class OnePass(object):
|
||||||
|
|
||||||
def __init__(self, path='op'):
|
def __init__(self, path='op'):
|
||||||
self._cli_path = path
|
self.cli_path = path
|
||||||
self._logged_in = False
|
self.config_file_path = os.path.expanduser('~/.op/config')
|
||||||
self._token = None
|
self.logged_in = False
|
||||||
self._subdomain = None
|
self.token = None
|
||||||
self._vault_password = None
|
self.subdomain = None
|
||||||
|
self.username = None
|
||||||
@property
|
self.secret_key = None
|
||||||
def cli_path(self):
|
self.master_password = None
|
||||||
return self._cli_path
|
|
||||||
|
|
||||||
def get_token(self):
|
def get_token(self):
|
||||||
if not self._subdomain and not self._vault_password:
|
# If the config file exists, assume an initial signin has taken place and try basic sign in
|
||||||
raise AnsibleLookupError('Both subdomain and password are required when logging in.')
|
if os.path.isfile(self.config_file_path):
|
||||||
args = ['signin', self._subdomain, '--output=raw']
|
|
||||||
rc, out, err = self._run(args, command_input=to_bytes(self._vault_password))
|
if not self.master_password:
|
||||||
self._token = out.strip()
|
raise AnsibleLookupError('Unable to sign in to 1Password. master_password is required.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
args = ['signin', '--output=raw']
|
||||||
|
|
||||||
|
if self.subdomain:
|
||||||
|
args = ['signin', self.subdomain, '--output=raw']
|
||||||
|
|
||||||
|
rc, out, err = self._run(args, command_input=to_bytes(self.master_password))
|
||||||
|
self.token = out.strip()
|
||||||
|
|
||||||
|
except AnsibleLookupError:
|
||||||
|
self.full_login()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Attempt a full sign in since there appears to be no existing sign in
|
||||||
|
self.full_login()
|
||||||
|
|
||||||
def assert_logged_in(self):
|
def assert_logged_in(self):
|
||||||
try:
|
try:
|
||||||
rc, out, err = self._run(['get', 'account'], ignore_errors=True)
|
rc, out, err = self._run(['get', 'account'], ignore_errors=True)
|
||||||
if rc != 1:
|
if rc == 0:
|
||||||
self._logged_in = True
|
self.logged_in = True
|
||||||
if not self._logged_in:
|
if not self.logged_in:
|
||||||
self.get_token()
|
self.get_token()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.ENOENT:
|
if e.errno == errno.ENOENT:
|
||||||
raise AnsibleLookupError("1Password CLI tool not installed in path on control machine")
|
raise AnsibleLookupError("1Password CLI tool '%s' not installed in path on control machine" % self.cli_path)
|
||||||
raise e
|
raise e
|
||||||
except AnsibleLookupError:
|
|
||||||
raise AnsibleLookupError("Not logged into 1Password: please run 'op signin' first, or provide both subdomain and vault_password.")
|
|
||||||
|
|
||||||
def get_raw(self, item_id, vault=None):
|
def get_raw(self, item_id, vault=None):
|
||||||
args = ["get", "item", item_id]
|
args = ["get", "item", item_id]
|
||||||
if vault is not None:
|
if vault is not None:
|
||||||
args += ['--vault={0}'.format(vault)]
|
args += ['--vault={0}'.format(vault)]
|
||||||
if not self._logged_in:
|
if not self.logged_in:
|
||||||
args += [to_bytes('--session=') + self._token]
|
args += [to_bytes('--session=') + self.token]
|
||||||
rc, output, dummy = self._run(args)
|
rc, output, dummy = self._run(args)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
@ -127,13 +172,29 @@ class OnePass(object):
|
||||||
output = self.get_raw(item_id, vault)
|
output = self.get_raw(item_id, vault)
|
||||||
return self._parse_field(output, field, section) if output != '' else ''
|
return self._parse_field(output, field, section) if output != '' else ''
|
||||||
|
|
||||||
|
def full_login(self):
|
||||||
|
if None in [self.subdomain, self.username, self.secret_key, self.master_password]:
|
||||||
|
raise AnsibleLookupError('Unable to perform initial sign in to 1Password. '
|
||||||
|
'subdomain, username, secret_key, and master_password are required to perform initial sign in.')
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'signin',
|
||||||
|
'{0}.1password.com'.format(self.subdomain),
|
||||||
|
to_bytes(self.username),
|
||||||
|
to_bytes(self.secret_key),
|
||||||
|
'--output=raw',
|
||||||
|
]
|
||||||
|
|
||||||
|
rc, out, err = self._run(args, command_input=to_bytes(self.master_password))
|
||||||
|
self.token = out.strip()
|
||||||
|
|
||||||
def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False):
|
def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False):
|
||||||
command = [self.cli_path] + args
|
command = [self.cli_path] + args
|
||||||
p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
|
p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
|
||||||
out, err = p.communicate(input=command_input)
|
out, err = p.communicate(input=command_input)
|
||||||
rc = p.wait()
|
rc = p.wait()
|
||||||
if not ignore_errors and rc != expected_rc:
|
if not ignore_errors and rc != expected_rc:
|
||||||
raise AnsibleLookupError(err)
|
raise AnsibleLookupError(to_text(err))
|
||||||
return rc, out, err
|
return rc, out, err
|
||||||
|
|
||||||
def _parse_field(self, data_json, field_name, section_title=None):
|
def _parse_field(self, data_json, field_name, section_title=None):
|
||||||
|
@ -159,8 +220,10 @@ class LookupModule(LookupBase):
|
||||||
field = kwargs.get('field', 'password')
|
field = kwargs.get('field', 'password')
|
||||||
section = kwargs.get('section')
|
section = kwargs.get('section')
|
||||||
vault = kwargs.get('vault')
|
vault = kwargs.get('vault')
|
||||||
op._subdomain = kwargs.get('subdomain')
|
op.subdomain = kwargs.get('subdomain')
|
||||||
op._vault_password = kwargs.get('vault_password')
|
op.username = kwargs.get('username')
|
||||||
|
op.secret_key = kwargs.get('secret_key')
|
||||||
|
op.master_password = kwargs.get('master_password', kwargs.get('vault_password'))
|
||||||
|
|
||||||
op.assert_logged_in()
|
op.assert_logged_in()
|
||||||
|
|
||||||
|
|
|
@ -20,25 +20,46 @@ DOCUMENTATION = """
|
||||||
version_added: "2.6"
|
version_added: "2.6"
|
||||||
requirements:
|
requirements:
|
||||||
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
|
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
|
||||||
- must have already logged into 1Password using op CLI
|
short_description: fetch an entire item from 1Password
|
||||||
short_description: fetch raw json data from 1Password
|
|
||||||
description:
|
description:
|
||||||
- onepassword_raw wraps C(op) command line utility to fetch an entire item from 1Password
|
- C(onepassword_raw) wraps C(op) command line utility to fetch an entire item from 1Password
|
||||||
options:
|
options:
|
||||||
_terms:
|
_terms:
|
||||||
description: identifier(s) (UUID, name, or domain; case-insensitive) of item(s) to retrieve
|
description: identifier(s) (UUID, name, or domain; case-insensitive) of item(s) to retrieve
|
||||||
required: True
|
required: True
|
||||||
|
master_password:
|
||||||
|
description: The password used to unlock the specified vault.
|
||||||
|
default: None
|
||||||
|
version_added: '2.7'
|
||||||
|
aliases: ['vault_password']
|
||||||
|
section:
|
||||||
|
description: Item section containing the field to retrieve (case-insensitive). If absent will return first match from any section.
|
||||||
|
default: None
|
||||||
subdomain:
|
subdomain:
|
||||||
description: The 1Password subdomain to authenticate against.
|
description: The 1Password subdomain to authenticate against.
|
||||||
default: None
|
default: None
|
||||||
version_added: '2.7'
|
version_added: '2.7'
|
||||||
vault:
|
username:
|
||||||
description: vault containing the item to retrieve (case-insensitive); if absent will search all vaults
|
description: The username used to sign in.
|
||||||
default: None
|
|
||||||
vault_password:
|
|
||||||
description: The password used to unlock the specified vault.
|
|
||||||
default: None
|
|
||||||
version_added: '2.7'
|
version_added: '2.7'
|
||||||
|
secret_key:
|
||||||
|
description: The secret key used when performing an initial sign in.
|
||||||
|
version_added: '2.7'
|
||||||
|
vault:
|
||||||
|
description: Vault containing the item to retrieve (case-insensitive). If absent will search all vaults
|
||||||
|
default: None
|
||||||
|
notes:
|
||||||
|
- This lookup will use an existing 1Password session if one exists. If not, and you have already
|
||||||
|
performed an initial sign in (meaning C(~/.op/config exists)), then only the C(master_password) is required.
|
||||||
|
You may optionally specify C(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op).
|
||||||
|
- This lookup can perform an initial login by providing C(subdomain), C(username), C(secret_key), and C(master_password).
|
||||||
|
- Due to the B(very) sensitive nature of these credentials, it is B(highly) recommeneded that you only pass in the minial credentials
|
||||||
|
needed at any given time. Also, store these credentials in an Ansible Vault using a key that is equal to or greater in strength
|
||||||
|
to the 1Password master password.
|
||||||
|
- This lookup stores potentially sensitive data from 1Password as Ansible facts.
|
||||||
|
Facts are subject to caching if enabled, which means this data could be stored in clear text
|
||||||
|
on disk or in a database.
|
||||||
|
- Tested with C(op) version 0.5.3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EXAMPLES = """
|
EXAMPLES = """
|
||||||
|
@ -68,8 +89,10 @@ class LookupModule(LookupBase):
|
||||||
op = OnePass()
|
op = OnePass()
|
||||||
|
|
||||||
vault = kwargs.get('vault')
|
vault = kwargs.get('vault')
|
||||||
op._subdomain = kwargs.get('subdomain')
|
op.subdomain = kwargs.get('subdomain')
|
||||||
op._vault_password = kwargs.get('vault_password')
|
op.username = kwargs.get('username')
|
||||||
|
op.secret_key = kwargs.get('secret_key')
|
||||||
|
op.master_password = kwargs.get('master_password', kwargs.get('vault_password'))
|
||||||
|
|
||||||
op.assert_logged_in()
|
op.assert_logged_in()
|
||||||
|
|
||||||
|
|
|
@ -233,45 +233,45 @@ class TestOnePass(unittest.TestCase):
|
||||||
|
|
||||||
def test_onepassword_get(self):
|
def test_onepassword_get(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
query_generator = get_mock_query_generator()
|
query_generator = get_mock_query_generator()
|
||||||
for dummy, query, dummy, field_name, field_value in query_generator:
|
for dummy, query, dummy, field_name, field_value in query_generator:
|
||||||
self.assertEqual(field_value, op.get_field(query, field_name))
|
self.assertEqual(field_value, op.get_field(query, field_name))
|
||||||
|
|
||||||
def test_onepassword_get_raw(self):
|
def test_onepassword_get_raw(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
for entry in MOCK_ENTRIES:
|
for entry in MOCK_ENTRIES:
|
||||||
for query in entry['queries']:
|
for query in entry['queries']:
|
||||||
self.assertEqual(json.dumps(entry['output']), op.get_raw(query))
|
self.assertEqual(json.dumps(entry['output']), op.get_raw(query))
|
||||||
|
|
||||||
def test_onepassword_get_not_found(self):
|
def test_onepassword_get_not_found(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
self.assertEqual('', op.get_field('a fake query', 'a fake field'))
|
self.assertEqual('', op.get_field('a fake query', 'a fake field'))
|
||||||
|
|
||||||
def test_onepassword_get_with_section(self):
|
def test_onepassword_get_with_section(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
dummy, query, section_title, field_name, field_value = get_one_mock_query()
|
dummy, query, section_title, field_name, field_value = get_one_mock_query()
|
||||||
self.assertEqual(field_value, op.get_field(query, field_name, section=section_title))
|
self.assertEqual(field_value, op.get_field(query, field_name, section=section_title))
|
||||||
|
|
||||||
def test_onepassword_get_with_vault(self):
|
def test_onepassword_get_with_vault(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
entry, query, dummy, field_name, field_value = get_one_mock_query()
|
entry, query, dummy, field_name, field_value = get_one_mock_query()
|
||||||
for vault_query in [entry['vault_name'], entry['output']['vaultUuid']]:
|
for vault_query in [entry['vault_name'], entry['output']['vaultUuid']]:
|
||||||
self.assertEqual(field_value, op.get_field(query, field_name, vault=vault_query))
|
self.assertEqual(field_value, op.get_field(query, field_name, vault=vault_query))
|
||||||
|
|
||||||
def test_onepassword_get_with_wrong_vault(self):
|
def test_onepassword_get_with_wrong_vault(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
dummy, query, dummy, field_name, dummy = get_one_mock_query()
|
dummy, query, dummy, field_name, dummy = get_one_mock_query()
|
||||||
self.assertEqual('', op.get_field(query, field_name, vault='a fake vault'))
|
self.assertEqual('', op.get_field(query, field_name, vault='a fake vault'))
|
||||||
|
|
||||||
def test_onepassword_get_diff_case(self):
|
def test_onepassword_get_diff_case(self):
|
||||||
op = MockOnePass()
|
op = MockOnePass()
|
||||||
op._logged_in = True
|
op.logged_in = True
|
||||||
entry, query, section_title, field_name, field_value = get_one_mock_query()
|
entry, query, section_title, field_name, field_value = get_one_mock_query()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
field_value,
|
field_value,
|
||||||
|
|
Loading…
Reference in a new issue