1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

Add onepassword_doc lookup plugin (#7490)

* 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
This commit is contained in:
Sam Doran 2023-11-26 14:32:20 -05:00 committed by GitHub
parent 5adb7ab948
commit e0346d400f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 256 additions and 137 deletions

View file

@ -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.
'''

View file

@ -14,71 +14,28 @@ DOCUMENTATION = '''
- Scott Buchanan (@scottsb) - Scott Buchanan (@scottsb)
- Andrew Zenk (@azenk) - Andrew Zenk (@azenk)
- Sam Doran (@samdoran) - Sam Doran (@samdoran)
requirements: short_description: Fetch field values from 1Password
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/)
short_description: fetch field values from 1Password
description: description:
- P(community.general.onepassword#lookup) wraps the C(op) command line utility to fetch specific field values from 1Password. - 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: options:
_terms: _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 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: account_id:
description: The account ID to target.
type: str
version_added: 7.5.0 version_added: 7.5.0
username: domain:
description: The username used to sign in. version_added: 3.2.0
secret_key: field:
description: The secret key used when performing an initial sign in. description: Field to return from each matching item (case-insensitive).
default: 'password'
type: str
service_account_token: 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 version_added: 7.1.0
connect_host: extends_documentation_fragment:
description: The host for 1Password Connect. Must be used in combination with O(connect_token). - community.general.onepassword
type: str - community.general.onepassword.lookup
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
''' '''
EXAMPLES = """ EXAMPLES = """
@ -120,7 +77,7 @@ EXAMPLES = """
RETURN = """ RETURN = """
_raw: _raw:
description: field data requested description: Field data requested.
type: list type: list
elements: str elements: str
""" """
@ -624,7 +581,7 @@ class OnePassCLIv2(OnePassCLIBase):
class OnePass(object): class OnePass(object):
def __init__(self, subdomain=None, domain="1password.com", username=None, secret_key=None, master_password=None, 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.subdomain = subdomain
self.domain = domain self.domain = domain
self.username = username self.username = username
@ -639,9 +596,15 @@ class OnePass(object):
self.token = None self.token = None
self._config = OnePasswordConfig() 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() version = OnePassCLIBase.get_current_version()
for cls in OnePassCLIBase.__subclasses__(): for cls in OnePassCLIBase.__subclasses__():
if cls.supports_version == version.split(".")[0]: if cls.supports_version == version.split(".")[0]:
@ -715,10 +678,17 @@ class LookupModule(LookupBase):
connect_host = self.get_option("connect_host") connect_host = self.get_option("connect_host")
connect_token = self.get_option("connect_token") connect_token = self.get_option("connect_token")
if (connect_host or connect_token) and None in (connect_host, connect_token): op = OnePass(
raise AnsibleOptionsError("connect_host and connect_token are required together") subdomain=subdomain,
domain=domain,
op = OnePass(subdomain, domain, username, secret_key, master_password, service_account_token, account_id, connect_host, connect_token) 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() op.assert_logged_in()
values = [] values = []

View file

@ -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

View file

@ -15,67 +15,23 @@ DOCUMENTATION = '''
- Andrew Zenk (@azenk) - Andrew Zenk (@azenk)
- Sam Doran (@samdoran) - Sam Doran (@samdoran)
requirements: requirements:
- C(op) 1Password command line utility. See U(https://support.1password.com/command-line/) - C(op) 1Password command line utility
short_description: fetch an entire item from 1Password short_description: Fetch an entire item from 1Password
description: description:
- P(community.general.onepassword_raw#lookup) wraps C(op) command line utility to fetch an entire item from 1Password. - P(community.general.onepassword_raw#lookup) 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) (case-insensitive UUID or name) of item(s) to retrieve.
required: true 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: account_id:
description: The account ID to target.
type: str
version_added: 7.5.0 version_added: 7.5.0
username: domain:
description: The username used to sign in. version_added: 6.0.0
secret_key:
description: The secret key used when performing an initial sign in.
service_account_token: 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 version_added: 7.1.0
connect_host: extends_documentation_fragment:
description: The host for 1Password Connect. Must be used in combination with O(connect_token). - community.general.onepassword
type: str - community.general.onepassword.lookup
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
''' '''
EXAMPLES = """ EXAMPLES = """
@ -90,7 +46,7 @@ EXAMPLES = """
RETURN = """ RETURN = """
_raw: _raw:
description: field data requested description: Entire item requested.
type: list type: list
elements: dict elements: dict
""" """
@ -98,7 +54,6 @@ RETURN = """
import json import json
from ansible_collections.community.general.plugins.lookup.onepassword import OnePass from ansible_collections.community.general.plugins.lookup.onepassword import OnePass
from ansible.errors import AnsibleOptionsError
from ansible.plugins.lookup import LookupBase from ansible.plugins.lookup import LookupBase
@ -118,10 +73,17 @@ class LookupModule(LookupBase):
connect_host = self.get_option("connect_host") connect_host = self.get_option("connect_host")
connect_token = self.get_option("connect_token") connect_token = self.get_option("connect_token")
if (connect_host or connect_token) and None in (connect_host, connect_token): op = OnePass(
raise AnsibleOptionsError("connect_host and connect_token are required together") subdomain=subdomain,
domain=domain,
op = OnePass(subdomain, domain, username, secret_key, master_password, service_account_token, account_id, connect_host, connect_token) 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() op.assert_logged_in()
values = [] values = []

View file

@ -10,17 +10,11 @@ import pytest
from ansible_collections.community.general.plugins.lookup.onepassword import OnePass from ansible_collections.community.general.plugins.lookup.onepassword import OnePass
OP_VERSION_FIXTURES = [
"opv1",
"opv2"
]
@pytest.fixture @pytest.fixture
def fake_op(mocker): def fake_op(mocker):
def _fake_op(version): def _fake_op(version):
mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePassCLIBase.get_current_version", return_value=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" op._config._config_file_path = "/home/jin/.op/config"
mocker.patch.object(op._cli, "_run") mocker.patch.object(op._cli, "_run")

View file

@ -10,12 +10,6 @@ import itertools
import json import json
import pytest 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 .onepassword_common import MOCK_ENTRIES
from ansible.errors import AnsibleLookupError, AnsibleOptionsError 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( @pytest.mark.parametrize(
("args", "rc", "expected_call_args", "expected_call_kwargs", "expected"), ("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 = request.getfixturevalue(op_fixture)
op._cli.master_password = "master_pass" op._cli.master_password = "master_pass"
op._cli.signin() op._cli.signin()
print(op._cli.version)
op._cli._run.assert_called_once_with(['signin', '--raw'], command_input=b"master_pass") 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( @pytest.mark.parametrize(
("plugin", "connect_host", "connect_token"), ("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) op_lookup = lookup_loader.get(plugin)
mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePass._get_cli_class", OnePassCLIv2)
with pytest.raises(AnsibleOptionsError): with pytest.raises(AnsibleOptionsError):
op_lookup.run("login", vault_name="test vault", connect_host=connect_host, connect_token=connect_token) op_lookup.run("login", vault_name="test vault", connect_host=connect_host, connect_token=connect_token)