From e0346d400f24da7cd0013cbab592c93be02eacdc Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Sun, 26 Nov 2023 14:32:20 -0500 Subject: [PATCH] Add onepassword_doc lookup plugin (#7490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add onepassword_doc lookup plugin * Switch to a doc fragment * Add unit test * Update docs * Move parameter validation to the OnePass object This makes it built in for other lookup plugins using this class. * Use kwargs for OnePass instantiation There are enough parameters now that using them positionally can result in odd behavior. * Update tests Correct conftest file name so fixtures are discovered and loaded correctly Move constant so it doesn’t need to be imported Add a patch since the parameter validation moved to part of the class init * Use a lookup docs fragment * Correct plugin description --- plugins/doc_fragments/onepassword.py | 75 +++++++++++++ plugins/lookup/onepassword.py | 98 ++++++----------- plugins/lookup/onepassword_doc.py | 104 ++++++++++++++++++ plugins/lookup/onepassword_raw.py | 78 ++++--------- .../{onepassword_conftest.py => conftest.py} | 8 +- tests/unit/plugins/lookup/test_onepassword.py | 30 +++-- 6 files changed, 256 insertions(+), 137 deletions(-) create mode 100644 plugins/doc_fragments/onepassword.py create mode 100644 plugins/lookup/onepassword_doc.py rename tests/unit/plugins/lookup/{onepassword_conftest.py => conftest.py} (89%) diff --git a/plugins/doc_fragments/onepassword.py b/plugins/doc_fragments/onepassword.py new file mode 100644 index 0000000000..11bd6239a5 --- /dev/null +++ b/plugins/doc_fragments/onepassword.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + DOCUMENTATION = r''' +requirements: + - See U(https://support.1password.com/command-line/) +options: + master_password: + description: The password used to unlock the specified vault. + aliases: ['vault_password'] + type: str + section: + description: Item section containing the field to retrieve (case-insensitive). If absent will return first match from any section. + domain: + description: Domain of 1Password. + default: '1password.com' + type: str + subdomain: + description: The 1Password subdomain to authenticate against. + type: str + account_id: + description: The account ID to target. + type: str + username: + description: The username used to sign in. + type: str + secret_key: + description: The secret key used when performing an initial sign in. + type: str + service_account_token: + description: + - The access key for a service account. + - Only works with 1Password CLI version 2 or later. + type: str + vault: + description: Vault containing the item to retrieve (case-insensitive). If absent will search all vaults. + type: str + connect_host: + description: The host for 1Password Connect. Must be used in combination with O(connect_token). + type: str + env: + - name: OP_CONNECT_HOST + version_added: 8.1.0 + connect_token: + description: The token for 1Password Connect. Must be used in combination with O(connect_host). + type: str + env: + - name: OP_CONNECT_TOKEN + version_added: 8.1.0 +''' + + LOOKUP = r''' +options: {} +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), C(~/.config/op/config) or C(~/.config/.op/config) exists), then only the + O(master_password) is required. You may optionally specify O(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op). + - This lookup can perform an initial login by providing O(subdomain), O(username), O(secret_key), and O(master_password). + - Can target a specific account by providing the O(account_id). + - Due to the B(very) sensitive nature of these credentials, it is B(highly) recommended that you only pass in the minimal 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 2.7.2. +''' diff --git a/plugins/lookup/onepassword.py b/plugins/lookup/onepassword.py index 8fb88541ed..1b9a037113 100644 --- a/plugins/lookup/onepassword.py +++ b/plugins/lookup/onepassword.py @@ -14,71 +14,28 @@ DOCUMENTATION = ''' - Scott Buchanan (@scottsb) - Andrew Zenk (@azenk) - Sam Doran (@samdoran) - requirements: - - C(op) 1Password command line utility. See U(https://support.1password.com/command-line/) - short_description: fetch field values from 1Password + short_description: Fetch field values from 1Password description: - P(community.general.onepassword#lookup) wraps the C(op) command line utility to fetch specific field values from 1Password. + requirements: + - C(op) 1Password command line utility options: _terms: - description: identifier(s) (UUID, name, or subdomain; case-insensitive) of item(s) to retrieve. + description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. required: true - field: - description: field to return from each matching item (case-insensitive). - default: 'password' - master_password: - description: The password used to unlock the specified vault. - aliases: ['vault_password'] - section: - description: Item section containing the field to retrieve (case-insensitive). If absent will return first match from any section. - domain: - description: Domain of 1Password. - version_added: 3.2.0 - default: '1password.com' - type: str - subdomain: - description: The 1Password subdomain to authenticate against. account_id: - description: The account ID to target. - type: str version_added: 7.5.0 - username: - description: The username used to sign in. - secret_key: - description: The secret key used when performing an initial sign in. + domain: + version_added: 3.2.0 + field: + description: Field to return from each matching item (case-insensitive). + default: 'password' + type: str service_account_token: - description: - - The access key for a service account. - - Only works with 1Password CLI version 2 or later. - type: str version_added: 7.1.0 - connect_host: - description: The host for 1Password Connect. Must be used in combination with O(connect_token). - type: str - env: - - name: OP_CONNECT_HOST - version_added: 8.1.0 - connect_token: - description: The token for 1Password Connect. Must be used in combination with O(connect_host). - type: str - env: - - name: OP_CONNECT_TOKEN - version_added: 8.1.0 - vault: - description: Vault containing the item to retrieve (case-insensitive). If absent will search all vaults. - 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), C(~/.config/op/config) or C(~/.config/.op/config) exists), then only the - C(master_password) is required. You may optionally specify O(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op). - - This lookup can perform an initial login by providing O(subdomain), O(username), O(secret_key), and O(master_password). - - Can target a specific account by providing the O(account_id). - - Due to the B(very) sensitive nature of these credentials, it is B(highly) recommended that you only pass in the minimal 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 2.7.2 + extends_documentation_fragment: + - community.general.onepassword + - community.general.onepassword.lookup ''' EXAMPLES = """ @@ -120,7 +77,7 @@ EXAMPLES = """ RETURN = """ _raw: - description: field data requested + description: Field data requested. type: list elements: str """ @@ -624,7 +581,7 @@ class OnePassCLIv2(OnePassCLIBase): class OnePass(object): def __init__(self, subdomain=None, domain="1password.com", username=None, secret_key=None, master_password=None, - service_account_token=None, account_id=None, connect_host=None, connect_token=None): + service_account_token=None, account_id=None, connect_host=None, connect_token=None, cli_class=None): self.subdomain = subdomain self.domain = domain self.username = username @@ -639,9 +596,15 @@ class OnePass(object): self.token = None self._config = OnePasswordConfig() - self._cli = self._get_cli_class() + self._cli = self._get_cli_class(cli_class) + + if (self.connect_host or self.connect_token) and None in (self.connect_host, self.connect_token): + raise AnsibleOptionsError("connect_host and connect_token are required together") + + def _get_cli_class(self, cli_class=None): + if cli_class is not None: + return cli_class(self.subdomain, self.domain, self.username, self.secret_key, self.master_password, self.service_account_token) - def _get_cli_class(self): version = OnePassCLIBase.get_current_version() for cls in OnePassCLIBase.__subclasses__(): if cls.supports_version == version.split(".")[0]: @@ -715,10 +678,17 @@ class LookupModule(LookupBase): connect_host = self.get_option("connect_host") connect_token = self.get_option("connect_token") - if (connect_host or connect_token) and None in (connect_host, connect_token): - raise AnsibleOptionsError("connect_host and connect_token are required together") - - op = OnePass(subdomain, domain, username, secret_key, master_password, service_account_token, account_id, connect_host, connect_token) + op = OnePass( + subdomain=subdomain, + domain=domain, + username=username, + secret_key=secret_key, + master_password=master_password, + service_account_token=service_account_token, + account_id=account_id, + connect_host=connect_host, + connect_token=connect_token, + ) op.assert_logged_in() values = [] diff --git a/plugins/lookup/onepassword_doc.py b/plugins/lookup/onepassword_doc.py new file mode 100644 index 0000000000..ab24795df2 --- /dev/null +++ b/plugins/lookup/onepassword_doc.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: onepassword_doc + author: + - Sam Doran (@samdoran) + requirements: + - C(op) 1Password command line utility version 2 or later. + short_description: Fetch documents stored in 1Password + version_added: "8.1.0" + description: + - P(community.general.onepassword_doc#lookup) wraps C(op) command line utility to fetch one or more documents from 1Password. + notes: + - The document contents are a string exactly as stored in 1Password. + - This plugin requires C(op) version 2 or later. + + options: + _terms: + description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. + required: true + + extends_documentation_fragment: + - community.general.onepassword + - community.general.onepassword.lookup +''' + +EXAMPLES = """ +- name: Retrieve a private key from 1Password + ansible.builtin.debug: + var: lookup('community.general.onepassword_doc', 'Private key') +""" + +RETURN = """ + _raw: + description: Requested document + type: list + elements: string +""" + +from ansible_collections.community.general.plugins.lookup.onepassword import OnePass, OnePassCLIv2 +from ansible.errors import AnsibleLookupError +from ansible.module_utils.common.text.converters import to_bytes +from ansible.plugins.lookup import LookupBase + + +class OnePassCLIv2Doc(OnePassCLIv2): + def get_raw(self, item_id, vault=None, token=None): + args = ["document", "get", item_id] + if vault is not None: + args = [*args, "--vault={0}".format(vault)] + + if self.service_account_token: + if vault is None: + raise AnsibleLookupError("'vault' is required with 'service_account_token'") + + environment_update = {"OP_SERVICE_ACCOUNT_TOKEN": self.service_account_token} + return self._run(args, environment_update=environment_update) + + if token is not None: + args = [*args, to_bytes("--session=") + token] + + return self._run(args) + + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + self.set_options(var_options=variables, direct=kwargs) + + vault = self.get_option("vault") + subdomain = self.get_option("subdomain") + domain = self.get_option("domain", "1password.com") + username = self.get_option("username") + secret_key = self.get_option("secret_key") + master_password = self.get_option("master_password") + service_account_token = self.get_option("service_account_token") + account_id = self.get_option("account_id") + connect_host = self.get_option("connect_host") + connect_token = self.get_option("connect_token") + + op = OnePass( + subdomain=subdomain, + domain=domain, + username=username, + secret_key=secret_key, + master_password=master_password, + service_account_token=service_account_token, + account_id=account_id, + connect_host=connect_host, + connect_token=connect_token, + cli_class=OnePassCLIv2Doc, + ) + op.assert_logged_in() + + values = [] + for term in terms: + values.append(op.get_raw(term, vault)) + + return values diff --git a/plugins/lookup/onepassword_raw.py b/plugins/lookup/onepassword_raw.py index 70a786f890..3eef535a1c 100644 --- a/plugins/lookup/onepassword_raw.py +++ b/plugins/lookup/onepassword_raw.py @@ -15,67 +15,23 @@ DOCUMENTATION = ''' - Andrew Zenk (@azenk) - Sam Doran (@samdoran) requirements: - - C(op) 1Password command line utility. See U(https://support.1password.com/command-line/) - short_description: fetch an entire item from 1Password + - C(op) 1Password command line utility + short_description: Fetch an entire item from 1Password description: - P(community.general.onepassword_raw#lookup) wraps C(op) command line utility to fetch an entire item from 1Password. options: _terms: - description: identifier(s) (UUID, name, or domain; case-insensitive) of item(s) to retrieve. + description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. required: true - master_password: - description: The password used to unlock the specified vault. - aliases: ['vault_password'] - section: - description: Item section containing the field to retrieve (case-insensitive). If absent will return first match from any section. - subdomain: - description: The 1Password subdomain to authenticate against. - domain: - description: Domain of 1Password. - version_added: 6.0.0 - default: '1password.com' - type: str account_id: - description: The account ID to target. - type: str version_added: 7.5.0 - username: - description: The username used to sign in. - secret_key: - description: The secret key used when performing an initial sign in. + domain: + version_added: 6.0.0 service_account_token: - description: - - The access key for a service account. - - Only works with 1Password CLI version 2 or later. - type: string version_added: 7.1.0 - connect_host: - description: The host for 1Password Connect. Must be used in combination with O(connect_token). - type: str - env: - - name: OP_CONNECT_HOST - version_added: 8.1.0 - connect_token: - description: The token for 1Password Connect. Must be used in combination with O(connect_host). - type: str - env: - - name: OP_CONNECT_TOKEN - version_added: 8.1.0 - vault: - description: Vault containing the item to retrieve (case-insensitive). If absent will search all vaults. - 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 O(master_password) is required. - You may optionally specify O(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op). - - This lookup can perform an initial login by providing O(subdomain), O(username), O(secret_key), and O(master_password). - - Can target a specific account by providing the O(account_id). - - Due to the B(very) sensitive nature of these credentials, it is B(highly) recommended that you only pass in the minimal 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 2.7.0 + extends_documentation_fragment: + - community.general.onepassword + - community.general.onepassword.lookup ''' EXAMPLES = """ @@ -90,7 +46,7 @@ EXAMPLES = """ RETURN = """ _raw: - description: field data requested + description: Entire item requested. type: list elements: dict """ @@ -98,7 +54,6 @@ RETURN = """ import json from ansible_collections.community.general.plugins.lookup.onepassword import OnePass -from ansible.errors import AnsibleOptionsError from ansible.plugins.lookup import LookupBase @@ -118,10 +73,17 @@ class LookupModule(LookupBase): connect_host = self.get_option("connect_host") connect_token = self.get_option("connect_token") - if (connect_host or connect_token) and None in (connect_host, connect_token): - raise AnsibleOptionsError("connect_host and connect_token are required together") - - op = OnePass(subdomain, domain, username, secret_key, master_password, service_account_token, account_id, connect_host, connect_token) + op = OnePass( + subdomain=subdomain, + domain=domain, + username=username, + secret_key=secret_key, + master_password=master_password, + service_account_token=service_account_token, + account_id=account_id, + connect_host=connect_host, + connect_token=connect_token, + ) op.assert_logged_in() values = [] diff --git a/tests/unit/plugins/lookup/onepassword_conftest.py b/tests/unit/plugins/lookup/conftest.py similarity index 89% rename from tests/unit/plugins/lookup/onepassword_conftest.py rename to tests/unit/plugins/lookup/conftest.py index 18afae1a33..d4ae42ab86 100644 --- a/tests/unit/plugins/lookup/onepassword_conftest.py +++ b/tests/unit/plugins/lookup/conftest.py @@ -10,17 +10,11 @@ import pytest from ansible_collections.community.general.plugins.lookup.onepassword import OnePass -OP_VERSION_FIXTURES = [ - "opv1", - "opv2" -] - - @pytest.fixture def fake_op(mocker): def _fake_op(version): mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePassCLIBase.get_current_version", return_value=version) - op = OnePass(None, None, None, None, None) + op = OnePass() op._config._config_file_path = "/home/jin/.op/config" mocker.patch.object(op._cli, "_run") diff --git a/tests/unit/plugins/lookup/test_onepassword.py b/tests/unit/plugins/lookup/test_onepassword.py index b85a8b9902..dc00e5703d 100644 --- a/tests/unit/plugins/lookup/test_onepassword.py +++ b/tests/unit/plugins/lookup/test_onepassword.py @@ -10,12 +10,6 @@ import itertools import json import pytest -from .onepassword_conftest import ( # noqa: F401, pylint: disable=unused-import - OP_VERSION_FIXTURES, - fake_op, - opv1, - opv2, -) from .onepassword_common import MOCK_ENTRIES from ansible.errors import AnsibleLookupError, AnsibleOptionsError @@ -26,6 +20,12 @@ from ansible_collections.community.general.plugins.lookup.onepassword import ( ) +OP_VERSION_FIXTURES = [ + "opv1", + "opv2" +] + + @pytest.mark.parametrize( ("args", "rc", "expected_call_args", "expected_call_kwargs", "expected"), ( @@ -270,10 +270,21 @@ def test_signin(op_fixture, request): op = request.getfixturevalue(op_fixture) op._cli.master_password = "master_pass" op._cli.signin() - print(op._cli.version) op._cli._run.assert_called_once_with(['signin', '--raw'], command_input=b"master_pass") +def test_op_doc(mocker): + document_contents = "Document Contents\n" + + mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePass.assert_logged_in", return_value=True) + mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePassCLIBase._run", return_value=(0, document_contents, "")) + + op_lookup = lookup_loader.get("community.general.onepassword_doc") + result = op_lookup.run(["Private key doc"]) + + assert result == [document_contents] + + @pytest.mark.parametrize( ("plugin", "connect_host", "connect_token"), [ @@ -286,8 +297,11 @@ def test_signin(op_fixture, request): ) ] ) -def test_op_connect_partial_args(plugin, connect_host, connect_token): +def test_op_connect_partial_args(plugin, connect_host, connect_token, mocker): op_lookup = lookup_loader.get(plugin) + + mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePass._get_cli_class", OnePassCLIv2) + with pytest.raises(AnsibleOptionsError): op_lookup.run("login", vault_name="test vault", connect_host=connect_host, connect_token=connect_token)