Repository: ansible-collections/community.general

Issue:
[1Password] Support 1Password Connect
### Summary

While working with AWX EE, I need to connect to my 1Password vault. The right way to do so is by using a 1Password Connect server to perform the automation.
Alas, when doing so, the `onepassword`/`onepassword_raw` lookups are trying to perform the command `op account list` while it is not necessary at all. Even worse, it is not supported when using 1Password Connect, causing a fatal failure.

See the following image:
![image](https://user-images.githubusercontent.com/118750884/203250609-e8b13c67-2062-43ed-8b95-84b7f3088a7c.png)


### Issue Type

Feature Idea

### Component Name

onepassword

### Additional Information

Run the following with the command:
```bash
OP_CONNECT_HOST=op-connect.example.com
OP_CONNECT_TOKEN=my-very-secret-token
ansible-playbook op.yml
```

```yaml
# op.myl
- debug: msg={{ lookup('onepassword', 'My vault entry', field='username') }}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/onepassword_info.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/onepassword_info.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @Rylon
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
!component =plugins/lookup/onepassword.py
!component +plugins/lookup/onepassword_raw.py

Files identified in the description:
* [`plugins/lookup/onepassword.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/lookup/onepassword.py)
* [`plugins/lookup/onepassword_raw.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/lookup/onepassword_raw.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @azenk @samdoran @scottsb
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Very interesting. I haven't used 1Password Connect at all but would love to add it to the plugins.
I don't suppose it would be that hard compared to the rest of the work already done, with obviously the due respect to contributors.
All in all, in integrates really well with AWX, adding custom credential type for that case, which will then be easily used with any job template.
In fact, I am asking it because there is no real way to use an AWX EE with 1Password unless being really hacky, because you can't create your session in advance (and retain it).  The OP_SESSION you would obtain would not remain across your plays as it has to be from the controller to have this environment variable, not your hosts.
Therefore, you would have to pass the credentials in every onepassword lookups. Definitely not something we should do.

So yes, in the end I guess it would be a very nice feature to have and good for automation in general as it is what 1Password Connect is meant for :)
Is it not possible to pass env vars into an EE? If it's just a container, that shouldn't be difficult.

Regardless, the plugin needs to be updated to support 1Password Connect. What is a simple command to run to detect if 1Password Connect is in use?
It is as you can see in my screenshot, the two environment variables `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` are recognized. But the session is limited in time. And the config file would be empty once you start the container again. I think the only possible way would be to mount a volume dedicated to logging in and propagating the session. But that's again hacky as hell. Which is exactly why I ended up using OP Connect.

To detect if it is in use, 1Password CLI simply watch if the environment variables said above are set. If so, then OP Connect is used. It is exactly why it asks to unset them if you ever want to perform the `op account list` command.
The `op connect` commands are there to manage the 1Password's side of it, not your session or anything else as far as I know (and understood it)
I have just run into this also trying to run Ansible in a kubernetes pod connecting to the connect server in the same cluster. I do believe that if `op account list` was prevented from being run when the environment variables are set this would work. I will try to patch this when I have time and raise a PR (unless someone gets there first).

PS. I confirmed connect is working by just running commands with the cli in the container and the operator in the cluster is also working fine.

Resolving patch:
diff --git a/plugins/lookup/onepassword.py b/plugins/lookup/onepassword.py
--- a/plugins/lookup/onepassword.py
+++ b/plugins/lookup/onepassword.py
@@ -52,6 +52,18 @@
           - 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 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 @@ def __init__(
         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 @@ def __init__(
         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 @@ def assert_logged_in(self):
         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 @@ def _parse_field(self, data_json, field_name, section_title=None):
         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 @@ def get_raw(self, item_id, vault=None, token=None):
         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 @@ def signin(self):
 
 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 @@ def __init__(self, subdomain=None, domain="1password.com", username=None, secret
         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 @@ def _get_cli_class(self):
         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 @@ def run(self, terms, variables=None, **kwargs):
         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")
+
+        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)
+        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
--- a/plugins/lookup/onepassword_raw.py
+++ b/plugins/lookup/onepassword_raw.py
@@ -49,6 +49,18 @@
           - 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 @@
 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 @@ def run(self, terms, variables=None, **kwargs):
         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")
+
+        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)
+        op = OnePass(subdomain, domain, username, secret_key, master_password, service_account_token, account_id, connect_host, connect_token)
         op.assert_logged_in()
 
         values = []


Test patch:
diff --git a/tests/unit/plugins/lookup/test_onepassword.py b/tests/unit/plugins/lookup/test_onepassword.py
--- a/tests/unit/plugins/lookup/test_onepassword.py
+++ b/tests/unit/plugins/lookup/test_onepassword.py
@@ -18,7 +18,7 @@
 )
 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()