[PR #9728/410999df backport][stable-10] bitwarden lookup: add options to filter by collection_name and validate number of results (#9757)
bitwarden lookup: add options to filter by collection_name and validate number of results (#9728)
* feat(lookups/bitwarden): add collection_name filter
* feat(lookups/bitwarden): add result_count check
* docs(lookups/bitwarden): add changelog fragment
* Update changelogs/fragments/9728-bitwarden-collection-name-filter.yml
Co-authored-by: Felix Fontein <felix@fontein.de>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Felix Fontein <felix@fontein.de>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Felix Fontein <felix@fontein.de>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Felix Fontein <felix@fontein.de>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Felix Fontein <felix@fontein.de>
* fix(lookups/bitwarden): fix result_count check for multiple terms
* fix(lookups/bitwarden): Enforce mutual exclusion of 'collection_name' and 'collection_id'
* formatting(lookups/bitwarden): remove trailing whitespace
* Update plugins/lookup/bitwarden.py
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
* Update plugins/lookup/bitwarden.py
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
* formatting(lookups/bitwarden): remove trailing whitespace
---------
Co-authored-by: Felix Fontein <felix@fontein.de>
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
(cherry picked from commit 410999dffa
)
Co-authored-by: Jonas <jonas.switala@frequentis.com>
pull/9759/head
parent
df28c80946
commit
ebb150c3f9
|
@ -0,0 +1,2 @@
|
||||||
|
minor_changes:
|
||||||
|
- bitwarden lookup plugin - add new option ``collection_name`` to filter results by collection name, and new option ``result_count`` to validate number of results (https://github.com/ansible-collections/community.general/pull/9728).
|
|
@ -37,9 +37,17 @@ DOCUMENTATION = """
|
||||||
description: Field to fetch. Leave unset to fetch whole response.
|
description: Field to fetch. Leave unset to fetch whole response.
|
||||||
type: str
|
type: str
|
||||||
collection_id:
|
collection_id:
|
||||||
description: Collection ID to filter results by collection. Leave unset to skip filtering.
|
description:
|
||||||
|
- Collection ID to filter results by collection. Leave unset to skip filtering.
|
||||||
|
- O(collection_id) and O(collection_name) are mutually exclusive.
|
||||||
type: str
|
type: str
|
||||||
version_added: 6.3.0
|
version_added: 6.3.0
|
||||||
|
collection_name:
|
||||||
|
description:
|
||||||
|
- Collection name to filter results by collection. Leave unset to skip filtering.
|
||||||
|
- O(collection_id) and O(collection_name) are mutually exclusive.
|
||||||
|
type: str
|
||||||
|
version_added: 10.4.0
|
||||||
organization_id:
|
organization_id:
|
||||||
description: Organization ID to filter results by organization. Leave unset to skip filtering.
|
description: Organization ID to filter results by organization. Leave unset to skip filtering.
|
||||||
type: str
|
type: str
|
||||||
|
@ -48,6 +56,12 @@ DOCUMENTATION = """
|
||||||
description: Pass session key instead of reading from env.
|
description: Pass session key instead of reading from env.
|
||||||
type: str
|
type: str
|
||||||
version_added: 8.4.0
|
version_added: 8.4.0
|
||||||
|
result_count:
|
||||||
|
description:
|
||||||
|
- Number of results expected for the lookup query. Task will fail if O(result_count)
|
||||||
|
is set but does not match the number of query results. Leave empty to skip this check.
|
||||||
|
type: int
|
||||||
|
version_added: 10.4.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EXAMPLES = """
|
EXAMPLES = """
|
||||||
|
@ -85,6 +99,16 @@ EXAMPLES = """
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
{{ lookup('community.general.bitwarden', None, collection_id='bafba515-af11-47e6-abe3-af1200cd18b2') }}
|
{{ lookup('community.general.bitwarden', None, collection_id='bafba515-af11-47e6-abe3-af1200cd18b2') }}
|
||||||
|
|
||||||
|
- name: "Get all Bitwarden records from collection"
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ lookup('community.general.bitwarden', None, collection_name='my_collections/test_collection') }}
|
||||||
|
|
||||||
|
- name: "Get Bitwarden record named 'a_test', ensure there is exactly one match"
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ lookup('community.general.bitwarden', 'a_test', result_count=1) }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RETURN = """
|
RETURN = """
|
||||||
|
@ -99,7 +123,7 @@ RETURN = """
|
||||||
|
|
||||||
from subprocess import Popen, PIPE
|
from subprocess import Popen, PIPE
|
||||||
|
|
||||||
from ansible.errors import AnsibleError
|
from ansible.errors import AnsibleError, AnsibleOptionsError
|
||||||
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
||||||
from ansible.parsing.ajson import AnsibleJSONDecoder
|
from ansible.parsing.ajson import AnsibleJSONDecoder
|
||||||
from ansible.plugins.lookup import LookupBase
|
from ansible.plugins.lookup import LookupBase
|
||||||
|
@ -211,6 +235,24 @@ class Bitwarden(object):
|
||||||
|
|
||||||
return field_matches
|
return field_matches
|
||||||
|
|
||||||
|
def get_collection_ids(self, collection_name: str, organization_id=None) -> list[str]:
|
||||||
|
"""Return matching IDs of collections whose name is equal to collection_name."""
|
||||||
|
|
||||||
|
# Prepare set of params for Bitwarden CLI
|
||||||
|
params = ['list', 'collections', '--search', collection_name]
|
||||||
|
|
||||||
|
if organization_id:
|
||||||
|
params.extend(['--organizationid', organization_id])
|
||||||
|
|
||||||
|
out, err = self._run(params)
|
||||||
|
|
||||||
|
# This includes things that matched in different fields.
|
||||||
|
initial_matches = AnsibleJSONDecoder().raw_decode(out)[0]
|
||||||
|
|
||||||
|
# Filter to only return the ID of a collections with exactly matching name
|
||||||
|
return [item['id'] for item in initial_matches
|
||||||
|
if str(item.get('name')).lower() == collection_name.lower()]
|
||||||
|
|
||||||
|
|
||||||
class LookupModule(LookupBase):
|
class LookupModule(LookupBase):
|
||||||
|
|
||||||
|
@ -219,7 +261,9 @@ class LookupModule(LookupBase):
|
||||||
field = self.get_option('field')
|
field = self.get_option('field')
|
||||||
search_field = self.get_option('search')
|
search_field = self.get_option('search')
|
||||||
collection_id = self.get_option('collection_id')
|
collection_id = self.get_option('collection_id')
|
||||||
|
collection_name = self.get_option('collection_name')
|
||||||
organization_id = self.get_option('organization_id')
|
organization_id = self.get_option('organization_id')
|
||||||
|
result_count = self.get_option('result_count')
|
||||||
_bitwarden.session = self.get_option('bw_session')
|
_bitwarden.session = self.get_option('bw_session')
|
||||||
|
|
||||||
if not _bitwarden.unlocked:
|
if not _bitwarden.unlocked:
|
||||||
|
@ -228,7 +272,27 @@ class LookupModule(LookupBase):
|
||||||
if not terms:
|
if not terms:
|
||||||
terms = [None]
|
terms = [None]
|
||||||
|
|
||||||
return [_bitwarden.get_field(field, term, search_field, collection_id, organization_id) for term in terms]
|
if collection_name and collection_id:
|
||||||
|
raise AnsibleOptionsError("'collection_name' and 'collection_id' are mutually exclusive!")
|
||||||
|
elif collection_name:
|
||||||
|
collection_ids = _bitwarden.get_collection_ids(collection_name, organization_id)
|
||||||
|
if not collection_ids:
|
||||||
|
raise BitwardenException("No matching collections found!")
|
||||||
|
else:
|
||||||
|
collection_ids = [collection_id]
|
||||||
|
|
||||||
|
results = [
|
||||||
|
_bitwarden.get_field(field, term, search_field, collection_id, organization_id)
|
||||||
|
for collection_id in collection_ids
|
||||||
|
for term in terms
|
||||||
|
]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if result_count is not None and len(result) != result_count:
|
||||||
|
raise BitwardenException(
|
||||||
|
f"Number of results doesn't match result_count! ({len(result)} != {result_count})")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
_bitwarden = Bitwarden()
|
_bitwarden = Bitwarden()
|
||||||
|
|
|
@ -13,7 +13,7 @@ from ansible_collections.community.general.tests.unit.compat.mock import patch
|
||||||
from ansible.errors import AnsibleError
|
from ansible.errors import AnsibleError
|
||||||
from ansible.module_utils import six
|
from ansible.module_utils import six
|
||||||
from ansible.plugins.loader import lookup_loader
|
from ansible.plugins.loader import lookup_loader
|
||||||
from ansible_collections.community.general.plugins.lookup.bitwarden import Bitwarden
|
from ansible_collections.community.general.plugins.lookup.bitwarden import Bitwarden, BitwardenException
|
||||||
from ansible.parsing.ajson import AnsibleJSONEncoder
|
from ansible.parsing.ajson import AnsibleJSONEncoder
|
||||||
|
|
||||||
MOCK_COLLECTION_ID = "3b12a9da-7c49-40b8-ad33-aede017a7ead"
|
MOCK_COLLECTION_ID = "3b12a9da-7c49-40b8-ad33-aede017a7ead"
|
||||||
|
@ -131,7 +131,21 @@ MOCK_RECORDS = [
|
||||||
"reprompt": 0,
|
"reprompt": 0,
|
||||||
"revisionDate": "2024-14-15T11:30:00.000Z",
|
"revisionDate": "2024-14-15T11:30:00.000Z",
|
||||||
"type": 1
|
"type": 1
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"object": "collection",
|
||||||
|
"id": MOCK_COLLECTION_ID,
|
||||||
|
"organizationId": MOCK_ORGANIZATION_ID,
|
||||||
|
"name": "MOCK_COLLECTION",
|
||||||
|
"externalId": None
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object": "collection",
|
||||||
|
"id": "3b12a9da-7c49-40b8-ad33-aede017a8ead",
|
||||||
|
"organizationId": "3b12a9da-7c49-40b8-ad33-aede017a9ead",
|
||||||
|
"name": "some/other/collection",
|
||||||
|
"externalId": None
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -164,6 +178,9 @@ class MockBitwarden(Bitwarden):
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for item in MOCK_RECORDS:
|
for item in MOCK_RECORDS:
|
||||||
|
if item.get('object') != 'item':
|
||||||
|
continue
|
||||||
|
|
||||||
if search_value and not re.search(search_value, item.get('name')):
|
if search_value and not re.search(search_value, item.get('name')):
|
||||||
continue
|
continue
|
||||||
if collection_to_filter and collection_to_filter not in item.get('collectionIds', []):
|
if collection_to_filter and collection_to_filter not in item.get('collectionIds', []):
|
||||||
|
@ -172,6 +189,35 @@ class MockBitwarden(Bitwarden):
|
||||||
continue
|
continue
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return AnsibleJSONEncoder().encode(items), ''
|
return AnsibleJSONEncoder().encode(items), ''
|
||||||
|
elif args[1] == 'collections':
|
||||||
|
try:
|
||||||
|
search_value = args[args.index('--search') + 1]
|
||||||
|
except ValueError:
|
||||||
|
search_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection_to_filter = args[args.index('--collectionid') + 1]
|
||||||
|
except ValueError:
|
||||||
|
collection_to_filter = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
organization_to_filter = args[args.index('--organizationid') + 1]
|
||||||
|
except ValueError:
|
||||||
|
organization_to_filter = None
|
||||||
|
|
||||||
|
collections = []
|
||||||
|
for item in MOCK_RECORDS:
|
||||||
|
if item.get('object') != 'collection':
|
||||||
|
continue
|
||||||
|
|
||||||
|
if search_value and not re.search(search_value, item.get('name')):
|
||||||
|
continue
|
||||||
|
if collection_to_filter and collection_to_filter not in item.get('collectionIds', []):
|
||||||
|
continue
|
||||||
|
if organization_to_filter and item.get('organizationId') != organization_to_filter:
|
||||||
|
continue
|
||||||
|
collections.append(item)
|
||||||
|
return AnsibleJSONEncoder().encode(collections), ''
|
||||||
|
|
||||||
return '[]', ''
|
return '[]', ''
|
||||||
|
|
||||||
|
@ -261,3 +307,26 @@ class TestLookupModule(unittest.TestCase):
|
||||||
def test_bitwarden_plugin_full_collection_organization(self):
|
def test_bitwarden_plugin_full_collection_organization(self):
|
||||||
self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2]], self.lookup.run(None,
|
self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2]], self.lookup.run(None,
|
||||||
collection_id=MOCK_COLLECTION_ID, organization_id=MOCK_ORGANIZATION_ID)[0])
|
collection_id=MOCK_COLLECTION_ID, organization_id=MOCK_ORGANIZATION_ID)[0])
|
||||||
|
|
||||||
|
@patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden())
|
||||||
|
def test_bitwarden_plugin_collection_name_filter(self):
|
||||||
|
# all passwords from MOCK_COLLECTION
|
||||||
|
self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2]], self.lookup.run(None,
|
||||||
|
collection_name="MOCK_COLLECTION")[0])
|
||||||
|
# Existing collection, no results
|
||||||
|
self.assertEqual([], self.lookup.run(None, collection_name="some/other/collection")[0])
|
||||||
|
|
||||||
|
# Non-Existent collection
|
||||||
|
with self.assertRaises(BitwardenException):
|
||||||
|
self.lookup.run(None, collection_name="nonexistent")
|
||||||
|
|
||||||
|
@patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden())
|
||||||
|
def test_bitwarden_plugin_result_count_check(self):
|
||||||
|
self.lookup.run(None, collection_id=MOCK_COLLECTION_ID, organization_id=MOCK_ORGANIZATION_ID, result_count=2)
|
||||||
|
with self.assertRaises(BitwardenException):
|
||||||
|
self.lookup.run(None, collection_id=MOCK_COLLECTION_ID, organization_id=MOCK_ORGANIZATION_ID,
|
||||||
|
result_count=1)
|
||||||
|
|
||||||
|
self.lookup.run(None, organization_id=MOCK_ORGANIZATION_ID, result_count=3)
|
||||||
|
with self.assertRaises(BitwardenException):
|
||||||
|
self.lookup.run(None, organization_id=MOCK_ORGANIZATION_ID, result_count=0)
|
||||||
|
|
Loading…
Reference in New Issue