From a76537b24f496c3b69437b5092c4e136d4d79e97 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:02:54 +0100 Subject: [PATCH] [PR #7116/f8652571 backport][stable-8] Support 1Password Connect (#5588) (#7536) Support 1Password Connect (#5588) (#7116) * Support 1Password Connect (#5588) - Support 1Password Connect with the opv2 client * Follow pep8, be less explicit * Update changelog to include PR * 1Password Connect host and token are now also parameters * Get argument values from the environment or lookup arguments * Move imports * Force using Connect token and host at the same time * Update unit tests * Update documentation * Additional tests (cherry picked from commit f8652571f7a09a6dfc364d2ccedf77639ac92ff4) Co-authored-by: Xeryus Stokkel --- .../5588-support-1password-connect.yml | 3 ++ plugins/lookup/onepassword.py | 48 +++++++++++++++++-- plugins/lookup/onepassword_raw.py | 20 +++++++- tests/unit/plugins/lookup/test_onepassword.py | 39 ++++++++++++++- 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/5588-support-1password-connect.yml diff --git a/changelogs/fragments/5588-support-1password-connect.yml b/changelogs/fragments/5588-support-1password-connect.yml new file mode 100644 index 0000000000..bec2300d3f --- /dev/null +++ b/changelogs/fragments/5588-support-1password-connect.yml @@ -0,0 +1,3 @@ +minor_changes: + - onepassword lookup plugin - support 1Password Connect with the opv2 client by setting the connect_host and connect_token parameters (https://github.com/ansible-collections/community.general/pull/7116). + - onepassword_raw lookup plugin - support 1Password Connect with the opv2 client by setting the connect_host and connect_token parameters (https://github.com/ansible-collections/community.general/pull/7116) diff --git a/plugins/lookup/onepassword.py b/plugins/lookup/onepassword.py index b137ecab87..8fb88541ed 100644 --- a/plugins/lookup/onepassword.py +++ b/plugins/lookup/onepassword.py @@ -52,6 +52,18 @@ DOCUMENTATION = ''' - 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: @@ -119,7 +131,7 @@ import json import subprocess from ansible.plugins.lookup import LookupBase -from ansible.errors import AnsibleLookupError +from ansible.errors import AnsibleLookupError, AnsibleOptionsError from ansible.module_utils.common.process import get_bin_path from ansible.module_utils.common.text.converters import to_bytes, to_text from ansible.module_utils.six import with_metaclass @@ -139,6 +151,8 @@ class OnePassCLIBase(with_metaclass(abc.ABCMeta, object)): master_password=None, service_account_token=None, account_id=None, + connect_host=None, + connect_token=None, ): self.subdomain = subdomain self.domain = domain @@ -147,6 +161,8 @@ class OnePassCLIBase(with_metaclass(abc.ABCMeta, object)): self.secret_key = secret_key self.service_account_token = service_account_token self.account_id = account_id + self.connect_host = connect_host + self.connect_token = connect_token self._path = None self._version = None @@ -325,6 +341,10 @@ class OnePassCLIv1(OnePassCLIBase): return not bool(rc) def full_signin(self): + if self.connect_host or self.connect_token: + raise AnsibleLookupError( + "1Password Connect is not available with 1Password CLI version 1. Please use version 2 or later.") + if self.service_account_token: raise AnsibleLookupError( "1Password CLI version 1 does not support Service Accounts. Please use version 2 or later.") @@ -510,6 +530,9 @@ class OnePassCLIv2(OnePassCLIBase): return "" def assert_logged_in(self): + if self.connect_host and self.connect_token: + return True + if self.service_account_token: args = ["whoami"] environment_update = {"OP_SERVICE_ACCOUNT_TOKEN": self.service_account_token} @@ -569,6 +592,15 @@ class OnePassCLIv2(OnePassCLIBase): if vault is not None: args += ["--vault={0}".format(vault)] + if self.connect_host and self.connect_token: + if vault is None: + raise AnsibleLookupError("'vault' is required with 1Password Connect") + environment_update = { + "OP_CONNECT_HOST": self.connect_host, + "OP_CONNECT_TOKEN": self.connect_token, + } + return self._run(args, environment_update=environment_update) + if self.service_account_token: if vault is None: raise AnsibleLookupError("'vault' is required with 'service_account_token'") @@ -592,7 +624,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): + service_account_token=None, account_id=None, connect_host=None, connect_token=None): self.subdomain = subdomain self.domain = domain self.username = username @@ -600,6 +632,8 @@ class OnePass(object): self.master_password = master_password self.service_account_token = service_account_token self.account_id = account_id + self.connect_host = connect_host + self.connect_token = connect_token self.logged_in = False self.token = None @@ -612,7 +646,8 @@ class OnePass(object): for cls in OnePassCLIBase.__subclasses__(): if cls.supports_version == version.split(".")[0]: try: - return cls(self.subdomain, self.domain, self.username, self.secret_key, self.master_password, self.service_account_token, self.account_id) + return cls(self.subdomain, self.domain, self.username, self.secret_key, self.master_password, self.service_account_token, + self.account_id, self.connect_host, self.connect_token) except TypeError as e: raise AnsibleLookupError(e) @@ -677,8 +712,13 @@ class LookupModule(LookupBase): 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, domain, username, secret_key, master_password, service_account_token, account_id) + 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.assert_logged_in() values = [] diff --git a/plugins/lookup/onepassword_raw.py b/plugins/lookup/onepassword_raw.py index 2ce5095400..70a786f890 100644 --- a/plugins/lookup/onepassword_raw.py +++ b/plugins/lookup/onepassword_raw.py @@ -49,6 +49,18 @@ DOCUMENTATION = ''' - 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: @@ -86,6 +98,7 @@ RETURN = """ import json from ansible_collections.community.general.plugins.lookup.onepassword import OnePass +from ansible.errors import AnsibleOptionsError from ansible.plugins.lookup import LookupBase @@ -102,8 +115,13 @@ class LookupModule(LookupBase): 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, domain, username, secret_key, master_password, service_account_token, account_id) + 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.assert_logged_in() values = [] diff --git a/tests/unit/plugins/lookup/test_onepassword.py b/tests/unit/plugins/lookup/test_onepassword.py index ab7f3def29..b85a8b9902 100644 --- a/tests/unit/plugins/lookup/test_onepassword.py +++ b/tests/unit/plugins/lookup/test_onepassword.py @@ -18,7 +18,7 @@ from .onepassword_conftest import ( # noqa: F401, pylint: disable=unused-import ) from .onepassword_common import MOCK_ENTRIES -from ansible.errors import AnsibleLookupError +from ansible.errors import AnsibleLookupError, AnsibleOptionsError from ansible.plugins.loader import lookup_loader from ansible_collections.community.general.plugins.lookup.onepassword import ( OnePassCLIv1, @@ -82,6 +82,12 @@ def test_assert_logged_in_v2(mocker, args, out, expected_call_args, expected_cal assert result == expected +def test_assert_logged_in_v2_connect(): + op_cli = OnePassCLIv2(connect_host="http://localhost:8080", connect_token="foobar") + result = op_cli.assert_logged_in() + assert result + + def test_full_signin_v2(mocker): mocker.patch.object(OnePassCLIv2, "_run", return_value=[0, "", ""]) @@ -266,3 +272,34 @@ def test_signin(op_fixture, request): op._cli.signin() print(op._cli.version) op._cli._run.assert_called_once_with(['signin', '--raw'], command_input=b"master_pass") + + +@pytest.mark.parametrize( + ("plugin", "connect_host", "connect_token"), + [ + (plugin, connect_host, connect_token) + for plugin in ("community.general.onepassword", "community.general.onepassword_raw") + for (connect_host, connect_token) in + ( + ("http://localhost", None), + (None, "foobar"), + ) + ] +) +def test_op_connect_partial_args(plugin, connect_host, connect_token): + op_lookup = lookup_loader.get(plugin) + with pytest.raises(AnsibleOptionsError): + op_lookup.run("login", vault_name="test vault", connect_host=connect_host, connect_token=connect_token) + + +@pytest.mark.parametrize( + ("kwargs"), + ( + {"connect_host": "http://localhost", "connect_token": "foobar"}, + {"service_account_token": "foobar"}, + ) +) +def test_opv1_unsupported_features(kwargs): + op_cli = OnePassCLIv1(**kwargs) + with pytest.raises(AnsibleLookupError): + op_cli.full_signin()