diff options
Diffstat (limited to 'ansible_collections/community/general/plugins/lookup')
22 files changed, 994 insertions, 255 deletions
diff --git a/ansible_collections/community/general/plugins/lookup/bitwarden.py b/ansible_collections/community/general/plugins/lookup/bitwarden.py index 27de1afe6..2cb2d19a1 100644 --- a/ansible_collections/community/general/plugins/lookup/bitwarden.py +++ b/ansible_collections/community/general/plugins/lookup/bitwarden.py @@ -13,7 +13,7 @@ DOCUMENTATION = """ - bw (command line utility) - be logged into bitwarden - bitwarden vault unlocked - - C(BW_SESSION) environment variable set + - E(BW_SESSION) environment variable set short_description: Retrieve secrets from Bitwarden version_added: 5.4.0 description: @@ -25,7 +25,11 @@ DOCUMENTATION = """ type: list elements: str search: - description: Field to retrieve, for example C(name) or C(id). + description: + - Field to retrieve, for example V(name) or V(id). + - If set to V(id), only zero or one element can be returned. + Use the Jinja C(first) filter to get the only list element. + - When O(collection_id) is set, this field can be undefined to retrieve the whole collection records. type: str default: name version_added: 5.7.0 @@ -36,40 +40,57 @@ DOCUMENTATION = """ description: Collection ID to filter results by collection. Leave unset to skip filtering. type: str version_added: 6.3.0 + bw_session: + description: Pass session key instead of reading from env. + type: str + version_added: 8.4.0 """ EXAMPLES = """ -- name: "Get 'password' from Bitwarden record named 'a_test'" +- name: "Get 'password' from all Bitwarden records named 'a_test'" ansible.builtin.debug: msg: >- {{ lookup('community.general.bitwarden', 'a_test', field='password') }} -- name: "Get 'password' from Bitwarden record with id 'bafba515-af11-47e6-abe3-af1200cd18b2'" +- name: "Get 'password' from Bitwarden record with ID 'bafba515-af11-47e6-abe3-af1200cd18b2'" ansible.builtin.debug: msg: >- - {{ lookup('community.general.bitwarden', 'bafba515-af11-47e6-abe3-af1200cd18b2', search='id', field='password') }} + {{ lookup('community.general.bitwarden', 'bafba515-af11-47e6-abe3-af1200cd18b2', search='id', field='password') | first }} -- name: "Get 'password' from Bitwarden record named 'a_test' from collection" +- name: "Get 'password' from all Bitwarden records named 'a_test' from collection" ansible.builtin.debug: msg: >- {{ lookup('community.general.bitwarden', 'a_test', field='password', collection_id='bafba515-af11-47e6-abe3-af1200cd18b2') }} -- name: "Get full Bitwarden record named 'a_test'" +- name: "Get list of all full Bitwarden records named 'a_test'" ansible.builtin.debug: msg: >- {{ lookup('community.general.bitwarden', 'a_test') }} -- name: "Get custom field 'api_key' from Bitwarden record named 'a_test'" +- name: "Get custom field 'api_key' from all Bitwarden records named 'a_test'" ansible.builtin.debug: msg: >- {{ lookup('community.general.bitwarden', 'a_test', field='api_key') }} + +- name: "Get 'password' from all Bitwarden records named 'a_test', using given session key" + ansible.builtin.debug: + msg: >- + {{ lookup('community.general.bitwarden', 'a_test', field='password', bw_session='bXZ9B5TXi6...') }} + +- name: "Get all Bitwarden records from collection" + ansible.builtin.debug: + msg: >- + {{ lookup('community.general.bitwarden', None, collection_id='bafba515-af11-47e6-abe3-af1200cd18b2') }} """ RETURN = """ _raw: - description: List of requested field or JSON object of list of matches. + description: + - A one-element list that contains a list of requested fields or JSON objects of matches. + - If you use C(query), you get a list of lists. If you use C(lookup) without C(wantlist=true), + this always gets reduced to a list of field values or JSON objects. type: list - elements: raw + elements: list """ from subprocess import Popen, PIPE @@ -88,76 +109,120 @@ class Bitwarden(object): def __init__(self, path='bw'): self._cli_path = path + self._session = None @property def cli_path(self): return self._cli_path @property + def session(self): + return self._session + + @session.setter + def session(self, value): + self._session = value + + @property def unlocked(self): out, err = self._run(['status'], stdin="") decoded = AnsibleJSONDecoder().raw_decode(out)[0] return decoded['status'] == 'unlocked' def _run(self, args, stdin=None, expected_rc=0): + if self.session: + args += ['--session', self.session] + p = Popen([self.cli_path] + args, stdout=PIPE, stderr=PIPE, stdin=PIPE) out, err = p.communicate(to_bytes(stdin)) rc = p.wait() if rc != expected_rc: + if len(args) > 2 and args[0] == 'get' and args[1] == 'item' and b'Not found.' in err: + return 'null', '' raise BitwardenException(err) return to_text(out, errors='surrogate_or_strict'), to_text(err, errors='surrogate_or_strict') - def _get_matches(self, search_value, search_field, collection_id): + def _get_matches(self, search_value, search_field, collection_id=None): """Return matching records whose search_field is equal to key. """ # Prepare set of params for Bitwarden CLI - params = ['list', 'items', '--search', search_value] + if search_value: + if search_field == 'id': + params = ['get', 'item', search_value] + else: + params = ['list', 'items', '--search', search_value] + if collection_id: + params.extend(['--collectionid', collection_id]) + else: + if not collection_id: + raise AnsibleError("search_value is required if collection_id is not set.") - if collection_id: - params.extend(['--collectionid', collection_id]) + params = ['list', 'items', '--collectionid', collection_id] out, err = self._run(params) # This includes things that matched in different fields. initial_matches = AnsibleJSONDecoder().raw_decode(out)[0] + if search_field == 'id' or not search_value: + if initial_matches is None: + initial_matches = [] + else: + initial_matches = [initial_matches] + # Filter to only include results from the right field. return [item for item in initial_matches if item[search_field] == search_value] - def get_field(self, field, search_value, search_field="name", collection_id=None): + def get_field(self, field, search_value=None, search_field="name", collection_id=None): """Return a list of the specified field for records whose search_field match search_value and filtered by collection if collection has been provided. If field is None, return the whole record for each match. """ matches = self._get_matches(search_value, search_field, collection_id) - - if field in ['autofillOnPageLoad', 'password', 'passwordRevisionDate', 'totp', 'uris', 'username']: - return [match['login'][field] for match in matches] - elif not field: + if not field: return matches - else: - custom_field_matches = [] - for match in matches: + field_matches = [] + for match in matches: + # if there are no custom fields, then `match` has no key 'fields' + if 'fields' in match: + custom_field_found = False for custom_field in match['fields']: - if custom_field['name'] == field: - custom_field_matches.append(custom_field['value']) - if matches and not custom_field_matches: - raise AnsibleError("Custom field {field} does not exist in {search_value}".format(field=field, search_value=search_value)) - return custom_field_matches + if field == custom_field['name']: + field_matches.append(custom_field['value']) + custom_field_found = True + break + if custom_field_found: + continue + if 'login' in match and field in match['login']: + field_matches.append(match['login'][field]) + continue + if field in match: + field_matches.append(match[field]) + continue + + if matches and not field_matches: + raise AnsibleError("field {field} does not exist in {search_value}".format(field=field, search_value=search_value)) + + return field_matches class LookupModule(LookupBase): - def run(self, terms, variables=None, **kwargs): + def run(self, terms=None, variables=None, **kwargs): self.set_options(var_options=variables, direct=kwargs) field = self.get_option('field') search_field = self.get_option('search') collection_id = self.get_option('collection_id') + _bitwarden.session = self.get_option('bw_session') + if not _bitwarden.unlocked: raise AnsibleError("Bitwarden Vault locked. Run 'bw unlock'.") + if not terms: + return [_bitwarden.get_field(field, None, search_field, collection_id)] + return [_bitwarden.get_field(field, term, search_field, collection_id) for term in terms] diff --git a/ansible_collections/community/general/plugins/lookup/bitwarden_secrets_manager.py b/ansible_collections/community/general/plugins/lookup/bitwarden_secrets_manager.py new file mode 100644 index 000000000..2d6706bee --- /dev/null +++ b/ansible_collections/community/general/plugins/lookup/bitwarden_secrets_manager.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023, jantari (https://github.com/jantari) +# 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: bitwarden_secrets_manager + author: + - jantari (@jantari) + requirements: + - bws (command line utility) + short_description: Retrieve secrets from Bitwarden Secrets Manager + version_added: 7.2.0 + description: + - Retrieve secrets from Bitwarden Secrets Manager. + options: + _terms: + description: Secret ID(s) to fetch values for. + required: true + type: list + elements: str + bws_access_token: + description: The BWS access token to use for this lookup. + env: + - name: BWS_ACCESS_TOKEN + required: true + type: str +""" + +EXAMPLES = """ +- name: Get a secret relying on the BWS_ACCESS_TOKEN environment variable for authentication + ansible.builtin.debug: + msg: >- + {{ lookup("community.general.bitwarden_secrets_manager", "2bc23e48-4932-40de-a047-5524b7ddc972") }} + +- name: Get a secret passing an explicit access token for authentication + ansible.builtin.debug: + msg: >- + {{ + lookup( + "community.general.bitwarden_secrets_manager", + "2bc23e48-4932-40de-a047-5524b7ddc972", + bws_access_token="9.4f570d14-4b54-42f5-bc07-60f4450b1db5.YmluYXJ5LXNvbWV0aGluZy0xMjMK:d2h5IGhlbGxvIHRoZXJlCg==" + ) + }} + +- name: Get two different secrets each using a different access token for authentication + ansible.builtin.debug: + msg: + - '{{ lookup("community.general.bitwarden_secrets_manager", "2bc23e48-4932-40de-a047-5524b7ddc972", bws_access_token=token1) }}' + - '{{ lookup("community.general.bitwarden_secrets_manager", "9d89af4c-eb5d-41f5-bb0f-4ae81215c768", bws_access_token=token2) }}' + vars: + token1: "9.4f570d14-4b54-42f5-bc07-60f4450b1db5.YmluYXJ5LXNvbWV0aGluZy0xMjMK:d2h5IGhlbGxvIHRoZXJlCg==" + token2: "1.69b72797-6ea9-4687-a11e-848e41a30ae6.YW5zaWJsZSBpcyBncmVhdD8K:YW5zaWJsZSBpcyBncmVhdAo=" + +- name: Get just the value of a secret + ansible.builtin.debug: + msg: >- + {{ lookup("community.general.bitwarden_secrets_manager", "2bc23e48-4932-40de-a047-5524b7ddc972").value }} +""" + +RETURN = """ + _raw: + description: List containing one or more secrets. + type: list + elements: dict +""" + +from subprocess import Popen, PIPE + +from ansible.errors import AnsibleLookupError +from ansible.module_utils.common.text.converters import to_text +from ansible.parsing.ajson import AnsibleJSONDecoder +from ansible.plugins.lookup import LookupBase + + +class BitwardenSecretsManagerException(AnsibleLookupError): + pass + + +class BitwardenSecretsManager(object): + def __init__(self, path='bws'): + self._cli_path = path + + @property + def cli_path(self): + return self._cli_path + + def _run(self, args, stdin=None): + p = Popen([self.cli_path] + args, stdout=PIPE, stderr=PIPE, stdin=PIPE) + out, err = p.communicate(stdin) + rc = p.wait() + return to_text(out, errors='surrogate_or_strict'), to_text(err, errors='surrogate_or_strict'), rc + + def get_secret(self, secret_id, bws_access_token): + """Get and return the secret with the given secret_id. + """ + + # Prepare set of params for Bitwarden Secrets Manager CLI + # Color output was not always disabled correctly with the default 'auto' setting so explicitly disable it. + params = [ + '--color', 'no', + '--access-token', bws_access_token, + 'get', 'secret', secret_id + ] + + out, err, rc = self._run(params) + if rc != 0: + raise BitwardenSecretsManagerException(to_text(err)) + + return AnsibleJSONDecoder().raw_decode(out)[0] + + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + self.set_options(var_options=variables, direct=kwargs) + bws_access_token = self.get_option('bws_access_token') + + return [_bitwarden_secrets_manager.get_secret(term, bws_access_token) for term in terms] + + +_bitwarden_secrets_manager = BitwardenSecretsManager() diff --git a/ansible_collections/community/general/plugins/lookup/collection_version.py b/ansible_collections/community/general/plugins/lookup/collection_version.py index 4d25585b8..33316fc2b 100644 --- a/ansible_collections/community/general/plugins/lookup/collection_version.py +++ b/ansible_collections/community/general/plugins/lookup/collection_version.py @@ -13,22 +13,22 @@ short_description: Retrieves the version of an installed collection description: - This lookup allows to query the version of an installed collection, and to determine whether a collection is installed at all. - - By default it returns C(none) for non-existing collections and C(*) for collections without a + - By default it returns V(none) for non-existing collections and V(*) for collections without a version number. The latter should only happen in development environments, or when installing a collection from git which has no version in its C(galaxy.yml). This behavior can be adjusted - by providing other values with I(result_not_found) and I(result_no_version). + by providing other values with O(result_not_found) and O(result_no_version). options: _terms: description: - The collections to look for. - - For example C(community.general). + - For example V(community.general). type: list elements: str required: true result_not_found: description: - The value to return when the collection could not be found. - - By default, C(none) is returned. + - By default, V(none) is returned. type: string default: ~ result_no_version: @@ -36,7 +36,7 @@ options: - The value to return when the collection has no version number. - This can happen for collections installed from git which do not have a version number in C(galaxy.yml). - - By default, C(*) is returned. + - By default, V(*) is returned. type: string default: '*' """ @@ -51,11 +51,11 @@ RETURN = """ _raw: description: - The version number of the collections listed as input. - - If a collection can not be found, it will return the value provided in I(result_not_found). - By default, this is C(none). + - If a collection can not be found, it will return the value provided in O(result_not_found). + By default, this is V(none). - If a collection can be found, but the version not identified, it will return the value provided in - I(result_no_version). By default, this is C(*). This can happen for collections installed - from git which do not have a version number in C(galaxy.yml). + O(result_no_version). By default, this is V(*). This can happen for collections installed + from git which do not have a version number in V(galaxy.yml). type: list elements: str """ @@ -98,15 +98,10 @@ def load_collection_meta(collection_pkg, no_version='*'): if os.path.exists(manifest_path): return load_collection_meta_manifest(manifest_path) - # Try to load galaxy.y(a)ml + # Try to load galaxy.yml galaxy_path = os.path.join(path, 'galaxy.yml') - galaxy_alt_path = os.path.join(path, 'galaxy.yaml') - # galaxy.yaml was only supported in ansible-base 2.10 and ansible-core 2.11. Support was removed - # in https://github.com/ansible/ansible/commit/595413d11346b6f26bb3d9df2d8e05f2747508a3 for - # ansible-core 2.12. - for path in (galaxy_path, galaxy_alt_path): - if os.path.exists(path): - return load_collection_meta_galaxy(path, no_version=no_version) + if os.path.exists(galaxy_path): + return load_collection_meta_galaxy(galaxy_path, no_version=no_version) return {} diff --git a/ansible_collections/community/general/plugins/lookup/consul_kv.py b/ansible_collections/community/general/plugins/lookup/consul_kv.py index f17f1b269..f8aadadc1 100644 --- a/ansible_collections/community/general/plugins/lookup/consul_kv.py +++ b/ansible_collections/community/general/plugins/lookup/consul_kv.py @@ -38,23 +38,20 @@ DOCUMENTATION = ''' default: localhost description: - The target to connect to, must be a resolvable address. - Will be determined from C(ANSIBLE_CONSUL_URL) if that is set. - - "C(ANSIBLE_CONSUL_URL) should look like this: C(https://my.consul.server:8500)" - env: - - name: ANSIBLE_CONSUL_URL + - Will be determined from E(ANSIBLE_CONSUL_URL) if that is set. ini: - section: lookup_consul key: host port: description: - The port of the target host to connect to. - - If you use C(ANSIBLE_CONSUL_URL) this value will be used from there. + - If you use E(ANSIBLE_CONSUL_URL) this value will be used from there. default: 8500 scheme: default: http description: - Whether to use http or https. - - If you use C(ANSIBLE_CONSUL_URL) this value will be used from there. + - If you use E(ANSIBLE_CONSUL_URL) this value will be used from there. validate_certs: default: true description: Whether to verify the ssl connection or not. @@ -71,7 +68,9 @@ DOCUMENTATION = ''' - section: lookup_consul key: client_cert url: - description: "The target to connect to, should look like this: C(https://my.consul.server:8500)." + description: + - The target to connect to. + - "Should look like this: V(https://my.consul.server:8500)." type: str version_added: 1.0.0 env: diff --git a/ansible_collections/community/general/plugins/lookup/dependent.py b/ansible_collections/community/general/plugins/lookup/dependent.py index 54714344e..31634e6e6 100644 --- a/ansible_collections/community/general/plugins/lookup/dependent.py +++ b/ansible_collections/community/general/plugins/lookup/dependent.py @@ -22,7 +22,7 @@ options: The name is the index that is used in the result object. The value is iterated over as described below. - If the value is a list, it is simply iterated over. - If the value is a dictionary, it is iterated over and returned as if they would be processed by the - R(ansible.builtin.dict2items filter,ansible_collections.ansible.builtin.dict2items_filter). + P(ansible.builtin.dict2items#filter) filter. - If the value is a string, it is evaluated as Jinja2 expressions which can access the previously chosen elements with C(item.<index_name>). The result must be a list or a dictionary. type: list diff --git a/ansible_collections/community/general/plugins/lookup/dig.py b/ansible_collections/community/general/plugins/lookup/dig.py index fa915220b..5be57cec7 100644 --- a/ansible_collections/community/general/plugins/lookup/dig.py +++ b/ansible_collections/community/general/plugins/lookup/dig.py @@ -21,7 +21,7 @@ DOCUMENTATION = ''' - In addition to (default) A record, it is also possible to specify a different record type that should be queried. This can be done by either passing-in additional parameter of format qtype=TYPE to the dig lookup, or by appending /TYPE to the FQDN being queried. - If multiple values are associated with the requested record, the results will be returned as a comma-separated list. - In such cases you may want to pass option I(wantlist=true) to the lookup call, or alternatively use C(query) instead of C(lookup), + In such cases you may want to pass option C(wantlist=true) to the lookup call, or alternatively use C(query) instead of C(lookup), which will result in the record values being returned as a list over which you can iterate later on. - By default, the lookup will rely on system-wide configured DNS servers for performing the query. It is also possible to explicitly specify DNS servers to query using the @DNS_SERVER_1,DNS_SERVER_2,...,DNS_SERVER_N notation. @@ -34,8 +34,8 @@ DOCUMENTATION = ''' qtype: description: - Record type to query. - - C(DLV) has been removed in community.general 6.0.0. - - C(CAA) has been added in community.general 6.3.0. + - V(DLV) has been removed in community.general 6.0.0. + - V(CAA) has been added in community.general 6.3.0. type: str default: 'A' choices: [A, ALL, AAAA, CAA, CNAME, DNAME, DNSKEY, DS, HINFO, LOC, MX, NAPTR, NS, NSEC3PARAM, PTR, RP, RRSIG, SOA, SPF, SRV, SSHFP, TLSA, TXT] @@ -51,17 +51,17 @@ DOCUMENTATION = ''' fail_on_error: description: - Abort execution on lookup errors. - - The default for this option will likely change to C(true) in the future. - The current default, C(false), is used for backwards compatibility, and will result in empty strings - or the string C(NXDOMAIN) in the result in case of errors. + - The default for this option will likely change to V(true) in the future. + The current default, V(false), is used for backwards compatibility, and will result in empty strings + or the string V(NXDOMAIN) in the result in case of errors. default: false type: bool version_added: 5.4.0 real_empty: description: - - Return empty result without empty strings, and return empty list instead of C(NXDOMAIN). - - The default for this option will likely change to C(true) in the future. - - This option will be forced to C(true) if multiple domains to be queried are specified. + - Return empty result without empty strings, and return empty list instead of V(NXDOMAIN). + - The default for this option will likely change to V(true) in the future. + - This option will be forced to V(true) if multiple domains to be queried are specified. default: false type: bool version_added: 6.0.0 @@ -70,6 +70,11 @@ DOCUMENTATION = ''' - "Class." type: str default: 'IN' + tcp: + description: Use TCP to lookup DNS records. + default: false + type: bool + version_added: 7.5.0 notes: - ALL is not a record per-se, merely the listed fields are available for any record results you retrieve in the form of a dictionary. - While the 'dig' lookup plugin supports anything which dnspython supports out of the box, only a subset can be converted into a dictionary. @@ -329,6 +334,7 @@ class LookupModule(LookupBase): flat = self.get_option('flat') fail_on_error = self.get_option('fail_on_error') real_empty = self.get_option('real_empty') + tcp = self.get_option('tcp') try: rdclass = dns.rdataclass.from_text(self.get_option('class')) except Exception as e: @@ -375,6 +381,8 @@ class LookupModule(LookupBase): fail_on_error = boolean(arg) elif opt == 'real_empty': real_empty = boolean(arg) + elif opt == 'tcp': + tcp = boolean(arg) continue @@ -408,7 +416,7 @@ class LookupModule(LookupBase): for domain in domains: try: - answers = myres.query(domain, qtype, rdclass=rdclass) + answers = myres.query(domain, qtype, rdclass=rdclass, tcp=tcp) for rdata in answers: s = rdata.to_text() if qtype.upper() == 'TXT': diff --git a/ansible_collections/community/general/plugins/lookup/dnstxt.py b/ansible_collections/community/general/plugins/lookup/dnstxt.py index 55067dc82..1ce511b84 100644 --- a/ansible_collections/community/general/plugins/lookup/dnstxt.py +++ b/ansible_collections/community/general/plugins/lookup/dnstxt.py @@ -22,8 +22,8 @@ DOCUMENTATION = ''' elements: string real_empty: description: - - Return empty result without empty strings, and return empty list instead of C(NXDOMAIN). - - The default for this option will likely change to C(true) in the future. + - Return empty result without empty strings, and return empty list instead of V(NXDOMAIN). + - The default for this option will likely change to V(true) in the future. default: false type: bool version_added: 6.0.0 diff --git a/ansible_collections/community/general/plugins/lookup/dsv.py b/ansible_collections/community/general/plugins/lookup/dsv.py index 91a9d9921..2dbb7db3e 100644 --- a/ansible_collections/community/general/plugins/lookup/dsv.py +++ b/ansible_collections/community/general/plugins/lookup/dsv.py @@ -13,15 +13,15 @@ short_description: Get secrets from Thycotic DevOps Secrets Vault version_added: 1.0.0 description: - Uses the Thycotic DevOps Secrets Vault Python SDK to get Secrets from a - DSV I(tenant) using a I(client_id) and I(client_secret). + DSV O(tenant) using a O(client_id) and O(client_secret). requirements: - python-dsv-sdk - https://pypi.org/project/python-dsv-sdk/ options: _terms: - description: The path to the secret, e.g. C(/staging/servers/web1). + description: The path to the secret, for example V(/staging/servers/web1). required: true tenant: - description: The first format parameter in the default I(url_template). + description: The first format parameter in the default O(url_template). env: - name: DSV_TENANT ini: @@ -31,7 +31,7 @@ options: tld: default: com description: The top-level domain of the tenant; the second format - parameter in the default I(url_template). + parameter in the default O(url_template). env: - name: DSV_TLD ini: @@ -47,7 +47,7 @@ options: key: client_id required: true client_secret: - description: The client secret associated with the specific I(client_id). + description: The client secret associated with the specific O(client_id). env: - name: DSV_CLIENT_SECRET ini: diff --git a/ansible_collections/community/general/plugins/lookup/etcd.py b/ansible_collections/community/general/plugins/lookup/etcd.py index d6a12293e..5135e7487 100644 --- a/ansible_collections/community/general/plugins/lookup/etcd.py +++ b/ansible_collections/community/general/plugins/lookup/etcd.py @@ -24,7 +24,7 @@ DOCUMENTATION = ''' required: true url: description: - - Environment variable with the url for the etcd server + - Environment variable with the URL for the etcd server default: 'http://127.0.0.1:4001' env: - name: ANSIBLE_ETCD_URL @@ -39,6 +39,10 @@ DOCUMENTATION = ''' - toggle checking that the ssl certificates are valid, you normally only want to turn this off with self-signed certs. default: true type: boolean + seealso: + - module: community.general.etcd3 + - plugin: community.general.etcd3 + plugin_type: lookup ''' EXAMPLES = ''' @@ -50,7 +54,7 @@ EXAMPLES = ''' ansible.builtin.debug: msg: "{{ lookup('community.general.etcd', 'foo', 'bar', 'baz') }}" -- name: "since Ansible 2.5 you can set server options inline" +- name: "you can set server options inline" ansible.builtin.debug: msg: "{{ lookup('community.general.etcd', 'foo', version='v2', url='http://192.168.0.27:4001') }}" ''' @@ -58,7 +62,7 @@ EXAMPLES = ''' RETURN = ''' _raw: description: - - list of values associated with input keys + - List of values associated with input keys. type: list elements: string ''' diff --git a/ansible_collections/community/general/plugins/lookup/etcd3.py b/ansible_collections/community/general/plugins/lookup/etcd3.py index 7f0a0cf90..0bda006e3 100644 --- a/ansible_collections/community/general/plugins/lookup/etcd3.py +++ b/ansible_collections/community/general/plugins/lookup/etcd3.py @@ -32,10 +32,10 @@ DOCUMENTATION = ''' default: false endpoints: description: - - Counterpart of C(ETCDCTL_ENDPOINTS) environment variable. - Specify the etcd3 connection with and URL form eg. C(https://hostname:2379) or C(<host>:<port>) form. - - The C(host) part is overwritten by I(host) option, if defined. - - The C(port) part is overwritten by I(port) option, if defined. + - Counterpart of E(ETCDCTL_ENDPOINTS) environment variable. + Specify the etcd3 connection with and URL form, for example V(https://hostname:2379), or V(<host>:<port>) form. + - The V(host) part is overwritten by O(host) option, if defined. + - The V(port) part is overwritten by O(port) option, if defined. env: - name: ETCDCTL_ENDPOINTS default: '127.0.0.1:2379' @@ -43,12 +43,12 @@ DOCUMENTATION = ''' host: description: - etcd3 listening client host. - - Takes precedence over I(endpoints). + - Takes precedence over O(endpoints). type: str port: description: - etcd3 listening client port. - - Takes precedence over I(endpoints). + - Takes precedence over O(endpoints). type: int ca_cert: description: @@ -89,13 +89,13 @@ DOCUMENTATION = ''' type: str notes: - - I(host) and I(port) options take precedence over (endpoints) option. - - The recommended way to connect to etcd3 server is using C(ETCDCTL_ENDPOINT) - environment variable and keep I(endpoints), I(host), and I(port) unused. + - O(host) and O(port) options take precedence over (endpoints) option. + - The recommended way to connect to etcd3 server is using E(ETCDCTL_ENDPOINT) + environment variable and keep O(endpoints), O(host), and O(port) unused. seealso: - module: community.general.etcd3 - - ref: ansible_collections.community.general.etcd_lookup - description: The etcd v2 lookup. + - plugin: community.general.etcd + plugin_type: lookup requirements: - "etcd3 >= 0.10" diff --git a/ansible_collections/community/general/plugins/lookup/filetree.py b/ansible_collections/community/general/plugins/lookup/filetree.py index f12cc4519..2131de99a 100644 --- a/ansible_collections/community/general/plugins/lookup/filetree.py +++ b/ansible_collections/community/general/plugins/lookup/filetree.py @@ -65,7 +65,7 @@ RETURN = r""" src: description: - Full path to file. - - Not returned when I(item.state) is set to C(directory). + - Not returned when RV(_raw[].state) is set to V(directory). type: path root: description: Allows filtering by original location. diff --git a/ansible_collections/community/general/plugins/lookup/flattened.py b/ansible_collections/community/general/plugins/lookup/flattened.py index e955b6478..0071417a0 100644 --- a/ansible_collections/community/general/plugins/lookup/flattened.py +++ b/ansible_collections/community/general/plugins/lookup/flattened.py @@ -19,7 +19,7 @@ DOCUMENTATION = ''' elements: raw required: true notes: - - Unlike the R(items lookup,ansible_collections.ansible.builtin.items_lookup) which only flattens 1 level, + - Unlike the P(ansible.builtin.items#lookup) lookup which only flattens 1 level, this plugin will continue to flatten until it cannot find lists anymore. - Aka highlander plugin, there can only be one (list). ''' diff --git a/ansible_collections/community/general/plugins/lookup/github_app_access_token.py b/ansible_collections/community/general/plugins/lookup/github_app_access_token.py new file mode 100644 index 000000000..5cd99b81c --- /dev/null +++ b/ansible_collections/community/general/plugins/lookup/github_app_access_token.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023, Poh Wei Sheng <weisheng-p@hotmail.sg> +# GNU General Public License v3.0+ (see COPYING 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: github_app_access_token + author: + - Poh Wei Sheng (@weisheng-p) + short_description: Obtain short-lived Github App Access tokens + version_added: '8.2.0' + requirements: + - jwt (https://github.com/GehirnInc/python-jwt) + description: + - This generates a Github access token that can be used with a C(git) command, if you use a Github App. + options: + key_path: + description: + - Path to your private key. + required: true + type: path + app_id: + description: + - Your GitHub App ID, you can find this in the Settings page. + required: true + type: str + installation_id: + description: + - The installation ID that contains the git repository you would like access to. + - As of 2023-12-24, this can be found via Settings page > Integrations > Application. The last part of the URL in the + configure button is the installation ID. + - Alternatively, you can use PyGithub (U(https://github.com/PyGithub/PyGithub)) to get your installation ID. + required: true + type: str + token_expiry: + description: + - How long the token should last for in seconds. + default: 600 + type: int +''' + +EXAMPLES = ''' +- name: Get access token to be used for git checkout with app_id=123456, installation_id=64209 + ansible.builtin.git: + repo: >- + https://x-access-token:{{ github_token }}@github.com/hidden_user/super-secret-repo.git + dest: /srv/checkout + vars: + github_token: >- + lookup('community.general.github_app_access_token', key_path='/home/to_your/key', + app_id='123456', installation_id='64209') +''' + +RETURN = ''' + _raw: + description: A one-element list containing your GitHub access token. + type: list + elements: str +''' + + +try: + from jwt import JWT, jwk_from_pem + HAS_JWT = True +except ImportError: + HAS_JWT = False + +import time +import json +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible.utils.display import Display + +if HAS_JWT: + jwt_instance = JWT() +else: + jwk_from_pem = None + jwt_instance = None + +display = Display() + + +def read_key(path): + try: + with open(path, 'rb') as pem_file: + return jwk_from_pem(pem_file.read()) + except Exception as e: + raise AnsibleError("Error while parsing key file: {0}".format(e)) + + +def encode_jwt(app_id, jwk, exp=600): + now = int(time.time()) + payload = { + 'iat': now, + 'exp': now + exp, + 'iss': app_id, + } + try: + return jwt_instance.encode(payload, jwk, alg='RS256') + except Exception as e: + raise AnsibleError("Error while encoding jwt: {0}".format(e)) + + +def post_request(generated_jwt, installation_id): + github_api_url = f'https://api.github.com/app/installations/{installation_id}/access_tokens' + headers = { + "Authorization": f'Bearer {generated_jwt}', + "Accept": "application/vnd.github.v3+json", + } + try: + response = open_url(github_api_url, headers=headers, method='POST') + except HTTPError as e: + try: + error_body = json.loads(e.read().decode()) + display.vvv("Error returned: {0}".format(error_body)) + except Exception: + error_body = {} + if e.code == 404: + raise AnsibleError("Github return error. Please confirm your installationd_id value is valid") + elif e.code == 401: + raise AnsibleError("Github return error. Please confirm your private key is valid") + raise AnsibleError("Unexpected data returned: {0} -- {1}".format(e, error_body)) + response_body = response.read() + try: + json_data = json.loads(response_body.decode('utf-8')) + except json.decoder.JSONDecodeError as e: + raise AnsibleError("Error while dencoding JSON respone from github: {0}".format(e)) + return json_data.get('token') + + +def get_token(key_path, app_id, installation_id, expiry=600): + jwk = read_key(key_path) + generated_jwt = encode_jwt(app_id, jwk, exp=expiry) + return post_request(generated_jwt, installation_id) + + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + if not HAS_JWT: + raise AnsibleError('Python jwt library is required. ' + 'Please install using "pip install jwt"') + + self.set_options(var_options=variables, direct=kwargs) + + t = get_token( + self.get_option('key_path'), + self.get_option('app_id'), + self.get_option('installation_id'), + self.get_option('token_expiry'), + ) + + return [t] diff --git a/ansible_collections/community/general/plugins/lookup/lmdb_kv.py b/ansible_collections/community/general/plugins/lookup/lmdb_kv.py index 0950249dc..a37cff956 100644 --- a/ansible_collections/community/general/plugins/lookup/lmdb_kv.py +++ b/ansible_collections/community/general/plugins/lookup/lmdb_kv.py @@ -15,7 +15,7 @@ DOCUMENTATION = ''' description: - This lookup returns a list of results from an LMDB DB corresponding to a list of items given to it. requirements: - - lmdb (python library https://lmdb.readthedocs.io/en/release/) + - lmdb (Python library U(https://lmdb.readthedocs.io/en/release/)) options: _terms: description: List of keys to query. diff --git a/ansible_collections/community/general/plugins/lookup/merge_variables.py b/ansible_collections/community/general/plugins/lookup/merge_variables.py index cd5fa5b7d..4fc33014c 100644 --- a/ansible_collections/community/general/plugins/lookup/merge_variables.py +++ b/ansible_collections/community/general/plugins/lookup/merge_variables.py @@ -10,16 +10,17 @@ DOCUMENTATION = """ author: - Roy Lenferink (@rlenferink) - Mark Ettema (@m-a-r-k-e) + - Alexander Petrenz (@alpex8) name: merge_variables short_description: merge variables with a certain suffix description: - This lookup returns the merged result of all variables in scope that match the given prefixes, suffixes, or - regular expressions, optionally. + regular expressions, optionally. version_added: 6.5.0 options: _terms: description: - - Depending on the value of I(pattern_type), this is a list of prefixes, suffixes, or regular expressions + - Depending on the value of O(pattern_type), this is a list of prefixes, suffixes, or regular expressions that will be used to match all variables that should be merged. required: true type: list @@ -45,11 +46,11 @@ DOCUMENTATION = """ override: description: - Return an error, print a warning or ignore it when a key will be overwritten. - - The default behavior C(error) makes the plugin fail when a key would be overwritten. - - When C(warn) and C(ignore) are used, note that it is important to know that the variables + - The default behavior V(error) makes the plugin fail when a key would be overwritten. + - When V(warn) and V(ignore) are used, note that it is important to know that the variables are sorted by name before being merged. Keys for later variables in this order will overwrite keys of the same name for variables earlier in this order. To avoid potential confusion, - better use I(override=error) whenever possible. + better use O(override=error) whenever possible. type: str default: 'error' choices: @@ -61,6 +62,13 @@ DOCUMENTATION = """ ini: - section: merge_variables_lookup key: override + groups: + description: + - Search for variables accross hosts that belong to the given groups. This allows to collect configuration pieces + accross different hosts (for example a service on a host with its database on another host). + type: list + elements: str + version_added: 8.5.0 """ EXAMPLES = """ @@ -131,22 +139,39 @@ def _verify_and_get_type(variable): class LookupModule(LookupBase): - def run(self, terms, variables=None, **kwargs): self.set_options(direct=kwargs) initial_value = self.get_option("initial_value", None) self._override = self.get_option('override', 'error') self._pattern_type = self.get_option('pattern_type', 'regex') + self._groups = self.get_option('groups', None) ret = [] for term in terms: if not isinstance(term, str): raise AnsibleError("Non-string type '{0}' passed, only 'str' types are allowed!".format(type(term))) - ret.append(self._merge_vars(term, initial_value, variables)) + if not self._groups: # consider only own variables + ret.append(self._merge_vars(term, initial_value, variables)) + else: # consider variables of hosts in given groups + cross_host_merge_result = initial_value + for host in variables["hostvars"]: + if self._is_host_in_allowed_groups(variables["hostvars"][host]["group_names"]): + cross_host_merge_result = self._merge_vars(term, cross_host_merge_result, variables["hostvars"][host]) + ret.append(cross_host_merge_result) return ret + def _is_host_in_allowed_groups(self, host_groups): + if 'all' in self._groups: + return True + + group_intersection = [host_group_name for host_group_name in host_groups if host_group_name in self._groups] + if group_intersection: + return True + + return False + def _var_matches(self, key, search_pattern): if self._pattern_type == "prefix": return key.startswith(search_pattern) @@ -162,7 +187,6 @@ class LookupModule(LookupBase): display.vvv("Merge variables with {0}: {1}".format(self._pattern_type, search_pattern)) var_merge_names = sorted([key for key in variables.keys() if self._var_matches(key, search_pattern)]) display.vvv("The following variables will be merged: {0}".format(var_merge_names)) - prev_var_type = None result = None diff --git a/ansible_collections/community/general/plugins/lookup/onepassword.py b/ansible_collections/community/general/plugins/lookup/onepassword.py index 0e78e4b1a..8ca95de0b 100644 --- a/ansible_collections/community/general/plugins/lookup/onepassword.py +++ b/ansible_collections/community/general/plugins/lookup/onepassword.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2018, Scott Buchanan <sbuchanan@ri.pn> +# Copyright (c) 2018, Scott Buchanan <scott@buchanan.works> # Copyright (c) 2016, Andrew Zenk <azenk@umn.edu> (lastpass.py used as starting point) # Copyright (c) 2018, 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) @@ -14,48 +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: - - C(onepassword) 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: _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. + account_id: + version_added: 7.5.0 domain: - description: Domain of 1Password. version_added: 3.2.0 - default: '1password.com' + field: + description: Field to return from each matching item (case-insensitive). + default: 'password' type: str - subdomain: - description: The 1Password subdomain to authenticate against. - username: - description: The username used to sign in. - secret_key: - description: The secret key used when performing an initial sign in. - 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 C(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op). - - This lookup can perform an initial login by providing C(subdomain), C(username), C(secret_key), and C(master_password). - - 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 + service_account_token: + version_added: 7.1.0 + extends_documentation_fragment: + - community.general.onepassword + - community.general.onepassword.lookup ''' EXAMPLES = """ @@ -74,24 +54,30 @@ EXAMPLES = """ - name: Retrieve password for HAL when not signed in to 1Password ansible.builtin.debug: - var: lookup('community.general.onepassword' - 'HAL 9000' - subdomain='Discovery' + var: lookup('community.general.onepassword', + 'HAL 9000', + subdomain='Discovery', master_password=vault_master_password) - name: Retrieve password for HAL when never signed in to 1Password ansible.builtin.debug: - var: lookup('community.general.onepassword' - 'HAL 9000' - subdomain='Discovery' - master_password=vault_master_password - username='tweety@acme.com' + var: lookup('community.general.onepassword', + 'HAL 9000', + subdomain='Discovery', + master_password=vault_master_password, + username='tweety@acme.com', secret_key=vault_secret_key) + +- name: Retrieve password from specific account + ansible.builtin.debug: + var: lookup('community.general.onepassword', + 'HAL 9000', + account_id='abc123') """ RETURN = """ _raw: - description: field data requested + description: Field data requested. type: list elements: str """ @@ -102,7 +88,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 @@ -110,15 +96,38 @@ from ansible.module_utils.six import with_metaclass from ansible_collections.community.general.plugins.module_utils.onepassword import OnePasswordConfig +def _lower_if_possible(value): + """Return the lower case version value, otherwise return the value""" + try: + return value.lower() + except AttributeError: + return value + + class OnePassCLIBase(with_metaclass(abc.ABCMeta, object)): bin = "op" - 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, + ): self.subdomain = subdomain self.domain = domain self.username = username self.master_password = master_password 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 @@ -286,7 +295,9 @@ class OnePassCLIv1(OnePassCLIBase): def assert_logged_in(self): args = ["get", "account"] - if self.subdomain: + if self.account_id: + args.extend(["--account", self.account_id]) + elif self.subdomain: account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain) args.extend(["--account", account]) @@ -295,6 +306,14 @@ 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.") + required_params = [ "subdomain", "username", @@ -315,6 +334,10 @@ class OnePassCLIv1(OnePassCLIBase): def get_raw(self, item_id, vault=None, token=None): args = ["get", "item", item_id] + + if self.account_id: + args.extend(["--account", self.account_id]) + if vault is not None: args += ["--vault={0}".format(vault)] @@ -442,6 +465,7 @@ class OnePassCLIv2(OnePassCLIBase): } """ data = json.loads(data_json) + field_name = _lower_if_possible(field_name) for field in data.get("fields", []): if section_title is None: # If the field name exists in the section, return that value @@ -450,28 +474,40 @@ class OnePassCLIv2(OnePassCLIBase): # If the field name doesn't exist in the section, match on the value of "label" # then "id" and return "value" - if field.get("label") == field_name: - return field["value"] + if field.get("label", "").lower() == field_name: + return field.get("value", "") - if field.get("id") == field_name: - return field["value"] + if field.get("id", "").lower() == field_name: + return field.get("value", "") - # Look at the section data and get an indentifier. The value of 'id' is either a unique ID + # Look at the section data and get an identifier. The value of 'id' is either a unique ID # or a human-readable string. If a 'label' field exists, prefer that since # it is the value visible in the 1Password UI when both 'id' and 'label' exist. section = field.get("section", {}) - current_section_title = section.get("label", section.get("id")) + section_title = _lower_if_possible(section_title) + + current_section_title = section.get("label", section.get("id", "")).lower() if section_title == current_section_title: # In the correct section. Check "label" then "id" for the desired field_name - if field.get("label") == field_name: - return field["value"] + if field.get("label", "").lower() == field_name: + return field.get("value", "") - if field.get("id") == field_name: - return field["value"] + if field.get("id", "").lower() == field_name: + return field.get("value", "") 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} + rc, out, err = self._run(args, environment_update=environment_update) + + return not bool(rc) + args = ["account", "list"] if self.subdomain: account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain) @@ -484,7 +520,9 @@ class OnePassCLIv2(OnePassCLIBase): # an interactive prompt. Only run 'op account get' after first listing accounts to see # if there are any previously configured accounts. args = ["account", "get"] - if self.subdomain: + if self.account_id: + args.extend(["--account", self.account_id]) + elif self.subdomain: account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain) args.extend(["--account", account]) @@ -515,8 +553,28 @@ class OnePassCLIv2(OnePassCLIBase): def get_raw(self, item_id, vault=None, token=None): args = ["item", "get", item_id, "--format", "json"] + + if self.account_id: + args.extend(["--account", self.account_id]) + 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'") + environment_update = {"OP_SERVICE_ACCOUNT_TOKEN": self.service_account_token} + return self._run(args, environment_update=environment_update) + if token is not None: args += [to_bytes("--session=") + token] @@ -533,25 +591,37 @@ class OnePassCLIv2(OnePassCLIBase): 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, cli_class=None): self.subdomain = subdomain self.domain = domain self.username = username self.secret_key = secret_key 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 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]: try: - return cls(self.subdomain, self.domain, self.username, self.secret_key, self.master_password) + 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) @@ -614,8 +684,22 @@ class LookupModule(LookupBase): username = self.get_option("username") secret_key = self.get_option("secret_key") master_password = self.get_option("master_password") - - op = OnePass(subdomain, domain, username, secret_key, 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, + ) op.assert_logged_in() values = [] diff --git a/ansible_collections/community/general/plugins/lookup/onepassword_doc.py b/ansible_collections/community/general/plugins/lookup/onepassword_doc.py new file mode 100644 index 000000000..ab24795df --- /dev/null +++ b/ansible_collections/community/general/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/ansible_collections/community/general/plugins/lookup/onepassword_raw.py b/ansible_collections/community/general/plugins/lookup/onepassword_raw.py index 9b87a3f61..3eef535a1 100644 --- a/ansible_collections/community/general/plugins/lookup/onepassword_raw.py +++ b/ansible_collections/community/general/plugins/lookup/onepassword_raw.py @@ -15,44 +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: - - C(onepassword_raw) 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: _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. + account_id: + version_added: 7.5.0 domain: - description: Domain of 1Password. version_added: 6.0.0 - default: '1password.com' - type: str - username: - description: The username used to sign in. - secret_key: - description: The secret key used when performing an initial sign in. - 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 C(master_password) is required. - You may optionally specify C(subdomain) in this scenario, otherwise the last used subdomain will be used by C(op). - - This lookup can perform an initial login by providing C(subdomain), C(username), C(secret_key), and C(master_password). - - 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 + service_account_token: + version_added: 7.1.0 + extends_documentation_fragment: + - community.general.onepassword + - community.general.onepassword.lookup ''' EXAMPLES = """ @@ -67,7 +46,7 @@ EXAMPLES = """ RETURN = """ _raw: - description: field data requested + description: Entire item requested. type: list elements: dict """ @@ -89,8 +68,22 @@ class LookupModule(LookupBase): 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, domain, username, secret_key, master_password) + 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/ansible_collections/community/general/plugins/lookup/passwordstore.py b/ansible_collections/community/general/plugins/lookup/passwordstore.py index 7e37a3785..7a6fca7a0 100644 --- a/ansible_collections/community/general/plugins/lookup/passwordstore.py +++ b/ansible_collections/community/general/plugins/lookup/passwordstore.py @@ -16,7 +16,7 @@ DOCUMENTATION = ''' - Enables Ansible to retrieve, create or update passwords from the passwordstore.org pass utility. It also retrieves YAML style keys stored as multilines in the passwordfile. - To avoid problems when accessing multiple secrets at once, add C(auto-expand-secmem) to - C(~/.gnupg/gpg-agent.conf). Where this is not possible, consider using I(lock=readwrite) instead. + C(~/.gnupg/gpg-agent.conf). Where this is not possible, consider using O(lock=readwrite) instead. options: _terms: description: query key. @@ -24,16 +24,16 @@ DOCUMENTATION = ''' directory: description: - The directory of the password store. - - If I(backend=pass), the default is C(~/.password-store) is used. - - If I(backend=gopass), then the default is the C(path) field in C(~/.config/gopass/config.yml), - falling back to C(~/.local/share/gopass/stores/root) if C(path) is not defined in the gopass config. + - If O(backend=pass), the default is V(~/.password-store) is used. + - If O(backend=gopass), then the default is the C(path) field in C(~/.config/gopass/config.yml), + falling back to V(~/.local/share/gopass/stores/root) if C(path) is not defined in the gopass config. type: path vars: - name: passwordstore env: - name: PASSWORD_STORE_DIR create: - description: Create the password if it does not already exist. Takes precedence over C(missing). + description: Create the password if it does not already exist. Takes precedence over O(missing). type: bool default: false overwrite: @@ -43,7 +43,7 @@ DOCUMENTATION = ''' umask: description: - Sets the umask for the created .gpg files. The first octed must be greater than 3 (user readable). - - Note pass' default value is C('077'). + - Note pass' default value is V('077'). env: - name: PASSWORD_STORE_UMASK version_added: 1.3.0 @@ -52,7 +52,7 @@ DOCUMENTATION = ''' type: bool default: false subkey: - description: Return a specific subkey of the password. When set to C(password), always returns the first line. + description: Return a specific subkey of the password. When set to V(password), always returns the first line. type: str default: password userpass: @@ -63,7 +63,7 @@ DOCUMENTATION = ''' type: integer default: 16 backup: - description: Used with C(overwrite=true). Backup the previous password in a subkey. + description: Used with O(overwrite=true). Backup the previous password in a subkey. type: bool default: false nosymbols: @@ -73,10 +73,10 @@ DOCUMENTATION = ''' missing: description: - List of preference about what to do if the password file is missing. - - If I(create=true), the value for this option is ignored and assumed to be C(create). - - If set to C(error), the lookup will error out if the passname does not exist. - - If set to C(create), the passname will be created with the provided length I(length) if it does not exist. - - If set to C(empty) or C(warn), will return a C(none) in case the passname does not exist. + - If O(create=true), the value for this option is ignored and assumed to be V(create). + - If set to V(error), the lookup will error out if the passname does not exist. + - If set to V(create), the passname will be created with the provided length O(length) if it does not exist. + - If set to V(empty) or V(warn), will return a V(none) in case the passname does not exist. When using C(lookup) and not C(query), this will be translated to an empty string. version_added: 3.1.0 type: str @@ -89,9 +89,9 @@ DOCUMENTATION = ''' lock: description: - How to synchronize operations. - - The default of C(write) only synchronizes write operations. - - C(readwrite) synchronizes all operations (including read). This makes sure that gpg-agent is never called in parallel. - - C(none) does not do any synchronization. + - The default of V(write) only synchronizes write operations. + - V(readwrite) synchronizes all operations (including read). This makes sure that gpg-agent is never called in parallel. + - V(none) does not do any synchronization. ini: - section: passwordstore_lookup key: lock @@ -104,8 +104,8 @@ DOCUMENTATION = ''' version_added: 4.5.0 locktimeout: description: - - Lock timeout applied when I(lock) is not C(none). - - Time with a unit suffix, C(s), C(m), C(h) for seconds, minutes, and hours, respectively. For example, C(900s) equals C(15m). + - Lock timeout applied when O(lock) is not V(none). + - Time with a unit suffix, V(s), V(m), V(h) for seconds, minutes, and hours, respectively. For example, V(900s) equals V(15m). - Correlates with C(pinentry-timeout) in C(~/.gnupg/gpg-agent.conf), see C(man gpg-agent) for details. ini: - section: passwordstore_lookup @@ -116,8 +116,8 @@ DOCUMENTATION = ''' backend: description: - Specify which backend to use. - - Defaults to C(pass), passwordstore.org's original pass utility. - - C(gopass) support is incomplete. + - Defaults to V(pass), passwordstore.org's original pass utility. + - V(gopass) support is incomplete. ini: - section: passwordstore_lookup key: backend @@ -129,6 +129,16 @@ DOCUMENTATION = ''' - pass - gopass version_added: 5.2.0 + timestamp: + description: Add the password generation information to the end of the file. + type: bool + default: true + version_added: 8.1.0 + preserve: + description: Include the old (edited) password inside the pass file. + type: bool + default: true + version_added: 8.1.0 notes: - The lookup supports passing all options as lookup parameters since community.general 6.0.0. ''' @@ -386,11 +396,13 @@ class LookupModule(LookupBase): # generate new password, insert old lines from current result and return new password newpass = self.get_newpass() datetime = time.strftime("%d/%m/%Y %H:%M:%S") - msg = newpass + '\n' - if self.passoutput[1:]: - msg += '\n'.join(self.passoutput[1:]) + '\n' - if self.paramvals['backup']: - msg += "lookup_pass: old password was {0} (Updated on {1})\n".format(self.password, datetime) + msg = newpass + if self.paramvals['preserve'] or self.paramvals['timestamp']: + msg += '\n' + if self.paramvals['preserve'] and self.passoutput[1:]: + msg += '\n'.join(self.passoutput[1:]) + '\n' + if self.paramvals['timestamp'] and self.paramvals['backup']: + msg += "lookup_pass: old password was {0} (Updated on {1})\n".format(self.password, datetime) try: check_output2([self.pass_cmd, 'insert', '-f', '-m', self.passname], input=msg, env=self.env) except (subprocess.CalledProcessError) as e: @@ -402,7 +414,9 @@ class LookupModule(LookupBase): # use pwgen to generate the password and insert values with pass -m newpass = self.get_newpass() datetime = time.strftime("%d/%m/%Y %H:%M:%S") - msg = newpass + '\n' + "lookup_pass: First generated by ansible on {0}\n".format(datetime) + msg = newpass + if self.paramvals['timestamp']: + msg += '\n' + "lookup_pass: First generated by ansible on {0}\n".format(datetime) try: check_output2([self.pass_cmd, 'insert', '-f', '-m', self.passname], input=msg, env=self.env) except (subprocess.CalledProcessError) as e: @@ -465,6 +479,8 @@ class LookupModule(LookupBase): 'backup': self.get_option('backup'), 'missing': self.get_option('missing'), 'umask': self.get_option('umask'), + 'timestamp': self.get_option('timestamp'), + 'preserve': self.get_option('preserve'), } def run(self, terms, variables, **kwargs): diff --git a/ansible_collections/community/general/plugins/lookup/random_string.py b/ansible_collections/community/general/plugins/lookup/random_string.py index 199aa1396..d3b29629d 100644 --- a/ansible_collections/community/general/plugins/lookup/random_string.py +++ b/ansible_collections/community/general/plugins/lookup/random_string.py @@ -16,6 +16,8 @@ DOCUMENTATION = r""" version_added: '3.2.0' description: - Generates random string based upon the given constraints. + - Uses L(random.SystemRandom,https://docs.python.org/3/library/random.html#random.SystemRandom), + so should be strong enough for cryptographic purposes. options: length: description: The length of the string. @@ -42,25 +44,25 @@ DOCUMENTATION = r""" - Special characters are taken from Python standard library C(string). See L(the documentation of string.punctuation,https://docs.python.org/3/library/string.html#string.punctuation) for which characters will be used. - - The choice of special characters can be changed to setting I(override_special). + - The choice of special characters can be changed to setting O(override_special). default: true type: bool min_numeric: description: - Minimum number of numeric characters in the string. - - If set, overrides I(numbers=false). + - If set, overrides O(numbers=false). default: 0 type: int min_upper: description: - Minimum number of uppercase alphabets in the string. - - If set, overrides I(upper=false). + - If set, overrides O(upper=false). default: 0 type: int min_lower: description: - Minimum number of lowercase alphabets in the string. - - If set, overrides I(lower=false). + - If set, overrides O(lower=false). default: 0 type: int min_special: @@ -70,14 +72,27 @@ DOCUMENTATION = r""" type: int override_special: description: - - Overide a list of special characters to use in the string. - - If set I(min_special) should be set to a non-default value. + - Override a list of special characters to use in the string. + - If set O(min_special) should be set to a non-default value. type: str override_all: description: - - Override all values of I(numbers), I(upper), I(lower), and I(special) with + - Override all values of O(numbers), O(upper), O(lower), and O(special) with the given list of characters. type: str + ignore_similar_chars: + description: + - Ignore similar characters, such as V(l) and V(1), or V(O) and V(0). + - These characters can be configured in O(similar_chars). + default: false + type: bool + version_added: 7.5.0 + similar_chars: + description: + - Override a list of characters not to be use in the string. + default: "il1LoO0" + type: str + version_added: 7.5.0 base64: description: - Returns base64 encoded string. @@ -101,7 +116,7 @@ EXAMPLES = r""" var: lookup('community.general.random_string', base64=True) # Example result: ['NHZ6eWN5Qk0='] -- name: Generate a random string with 1 lower, 1 upper, 1 number and 1 special char (atleast) +- name: Generate a random string with 1 lower, 1 upper, 1 number and 1 special char (at least) ansible.builtin.debug: var: lookup('community.general.random_string', min_lower=1, min_upper=1, min_special=1, min_numeric=1) # Example result: ['&Qw2|E[-'] @@ -171,9 +186,17 @@ class LookupModule(LookupBase): length = self.get_option("length") base64_flag = self.get_option("base64") override_all = self.get_option("override_all") + ignore_similar_chars = self.get_option("ignore_similar_chars") + similar_chars = self.get_option("similar_chars") values = "" available_chars_set = "" + if ignore_similar_chars: + number_chars = "".join([sc for sc in number_chars if sc not in similar_chars]) + lower_chars = "".join([sc for sc in lower_chars if sc not in similar_chars]) + upper_chars = "".join([sc for sc in upper_chars if sc not in similar_chars]) + special_chars = "".join([sc for sc in special_chars if sc not in similar_chars]) + if override_all: # Override all the values available_chars_set = override_all diff --git a/ansible_collections/community/general/plugins/lookup/revbitspss.py b/ansible_collections/community/general/plugins/lookup/revbitspss.py index 552970804..e4118e89e 100644 --- a/ansible_collections/community/general/plugins/lookup/revbitspss.py +++ b/ansible_collections/community/general/plugins/lookup/revbitspss.py @@ -25,7 +25,7 @@ options: elements: string base_url: description: - - This will be the base URL of the server, for example C(https://server-url-here). + - This will be the base URL of the server, for example V(https://server-url-here). required: true type: string api_key: @@ -100,7 +100,7 @@ class LookupModule(LookupBase): result = [] for term in terms: try: - display.vvv(u"Secret Server lookup of Secret with ID %s" % term) + display.vvv("Secret Server lookup of Secret with ID %s" % term) result.append({term: secret_server.get_pam_secret(term)}) except Exception as error: raise AnsibleError("Secret Server lookup failure: %s" % error.message) diff --git a/ansible_collections/community/general/plugins/lookup/tss.py b/ansible_collections/community/general/plugins/lookup/tss.py index 935b5f4b4..80105ff71 100644 --- a/ansible_collections/community/general/plugins/lookup/tss.py +++ b/ansible_collections/community/general/plugins/lookup/tss.py @@ -13,10 +13,10 @@ short_description: Get secrets from Thycotic Secret Server version_added: 1.0.0 description: - Uses the Thycotic Secret Server Python SDK to get Secrets from Secret - Server using token authentication with I(username) and I(password) on - the REST API at I(base_url). + Server using token authentication with O(username) and O(password) on + the REST API at O(base_url). - When using self-signed certificates the environment variable - C(REQUESTS_CA_BUNDLE) can be set to a file containing the trusted certificates + E(REQUESTS_CA_BUNDLE) can be set to a file containing the trusted certificates (in C(.pem) format). - For example, C(export REQUESTS_CA_BUNDLE='/etc/ssl/certs/ca-bundle.trust.crt'). requirements: @@ -26,8 +26,32 @@ options: description: The integer ID of the secret. required: true type: int + secret_path: + description: Indicate a full path of secret including folder and secret name when the secret ID is set to 0. + required: false + type: str + version_added: 7.2.0 + fetch_secret_ids_from_folder: + description: + - Boolean flag which indicates whether secret ids are in a folder is fetched by folder ID or not. + - V(true) then the terms will be considered as a folder IDs. Otherwise (default), they are considered as secret IDs. + required: false + type: bool + version_added: 7.1.0 + fetch_attachments: + description: + - Boolean flag which indicates whether attached files will get downloaded or not. + - The download will only happen if O(file_download_path) has been provided. + required: false + type: bool + version_added: 7.0.0 + file_download_path: + description: Indicate the file attachment download location. + required: false + type: path + version_added: 7.0.0 base_url: - description: The base URL of the server, e.g. C(https://localhost/SecretServer). + description: The base URL of the server, for example V(https://localhost/SecretServer). env: - name: TSS_BASE_URL ini: @@ -44,7 +68,7 @@ options: password: description: - The password associated with the supplied username. - - Required when I(token) is not provided. + - Required when O(token) is not provided. env: - name: TSS_PASSWORD ini: @@ -54,7 +78,7 @@ options: default: "" description: - The domain with which to request the OAuth2 Access Grant. - - Optional when I(token) is not provided. + - Optional when O(token) is not provided. - Requires C(python-tss-sdk) version 1.0.0 or greater. env: - name: TSS_DOMAIN @@ -66,7 +90,7 @@ options: token: description: - Existing token for Thycotic authorizer. - - If provided, I(username) and I(password) are not needed. + - If provided, O(username) and O(password) are not needed. - Requires C(python-tss-sdk) version 1.0.0 or greater. env: - name: TSS_TOKEN @@ -157,39 +181,101 @@ EXAMPLES = r""" tasks: - ansible.builtin.debug: msg: the password is {{ secret_password }} + +# Private key stores into certificate file which is attached with secret. +# If fetch_attachments=True then private key file will be download on specified path +# and file content will display in debug message. +- hosts: localhost + vars: + secret: >- + {{ + lookup( + 'community.general.tss', + 102, + fetch_attachments=True, + file_download_path='/home/certs', + base_url='https://secretserver.domain.com/SecretServer/', + token='thycotic_access_token' + ) + }} + tasks: + - ansible.builtin.debug: + msg: > + the private key is {{ + (secret['items'] + | items2dict(key_name='slug', + value_name='itemValue'))['private-key'] + }} + +# If fetch_secret_ids_from_folder=true then secret IDs are in a folder is fetched based on folder ID +- hosts: localhost + vars: + secret: >- + {{ + lookup( + 'community.general.tss', + 102, + fetch_secret_ids_from_folder=true, + base_url='https://secretserver.domain.com/SecretServer/', + token='thycotic_access_token' + ) + }} + tasks: + - ansible.builtin.debug: + msg: > + the secret id's are {{ + secret + }} + +# If secret ID is 0 and secret_path has value then secret is fetched by secret path +- hosts: localhost + vars: + secret: >- + {{ + lookup( + 'community.general.tss', + 0, + secret_path='\folderName\secretName' + base_url='https://secretserver.domain.com/SecretServer/', + username='user.name', + password='password' + ) + }} + tasks: + - ansible.builtin.debug: + msg: > + the password is {{ + (secret['items'] + | items2dict(key_name='slug', + value_name='itemValue'))['password'] + }} """ import abc - +import os from ansible.errors import AnsibleError, AnsibleOptionsError from ansible.module_utils import six from ansible.plugins.lookup import LookupBase from ansible.utils.display import Display try: - from thycotic.secrets.server import SecretServer, SecretServerError + from delinea.secrets.server import SecretServer, SecretServerError, PasswordGrantAuthorizer, DomainPasswordGrantAuthorizer, AccessTokenAuthorizer HAS_TSS_SDK = True + HAS_DELINEA_SS_SDK = True + HAS_TSS_AUTHORIZER = True except ImportError: try: - from delinea.secrets.server import SecretServer, SecretServerError + from thycotic.secrets.server import SecretServer, SecretServerError, PasswordGrantAuthorizer, DomainPasswordGrantAuthorizer, AccessTokenAuthorizer HAS_TSS_SDK = True + HAS_DELINEA_SS_SDK = False + HAS_TSS_AUTHORIZER = True except ImportError: SecretServer = None SecretServerError = None HAS_TSS_SDK = False - -try: - from thycotic.secrets.server import PasswordGrantAuthorizer, DomainPasswordGrantAuthorizer, AccessTokenAuthorizer - - HAS_TSS_AUTHORIZER = True -except ImportError: - try: - from delinea.secrets.server import PasswordGrantAuthorizer, DomainPasswordGrantAuthorizer, AccessTokenAuthorizer - - HAS_TSS_AUTHORIZER = True - except ImportError: + HAS_DELINEA_SS_SDK = False PasswordGrantAuthorizer = None DomainPasswordGrantAuthorizer = None AccessTokenAuthorizer = None @@ -211,13 +297,49 @@ class TSSClient(object): else: return TSSClientV0(**server_parameters) - def get_secret(self, term): + def get_secret(self, term, secret_path, fetch_file_attachments, file_download_path): display.debug("tss_lookup term: %s" % term) - secret_id = self._term_to_secret_id(term) - display.vvv(u"Secret Server lookup of Secret with ID %d" % secret_id) + if secret_id == 0 and secret_path: + fetch_secret_by_path = True + display.vvv(u"Secret Server lookup of Secret with path %s" % secret_path) + else: + fetch_secret_by_path = False + display.vvv(u"Secret Server lookup of Secret with ID %d" % secret_id) + + if fetch_file_attachments: + if fetch_secret_by_path: + obj = self._client.get_secret_by_path(secret_path, fetch_file_attachments) + else: + obj = self._client.get_secret(secret_id, fetch_file_attachments) + for i in obj['items']: + if file_download_path and os.path.isdir(file_download_path): + if i['isFile']: + try: + file_content = i['itemValue'].content + with open(os.path.join(file_download_path, str(obj['id']) + "_" + i['slug']), "wb") as f: + f.write(file_content) + except ValueError: + raise AnsibleOptionsError("Failed to download {0}".format(str(i['slug']))) + except AttributeError: + display.warning("Could not read file content for {0}".format(str(i['slug']))) + finally: + i['itemValue'] = "*** Not Valid For Display ***" + else: + raise AnsibleOptionsError("File download path does not exist") + return obj + else: + if fetch_secret_by_path: + return self._client.get_secret_by_path(secret_path, False) + else: + return self._client.get_secret_json(secret_id) + + def get_secret_ids_by_folderid(self, term): + display.debug("tss_lookup term: %s" % term) + folder_id = self._term_to_folder_id(term) + display.vvv(u"Secret Server lookup of Secret id's with Folder ID %d" % folder_id) - return self._client.get_secret_json(secret_id) + return self._client.get_secret_ids_by_folderid(folder_id) @staticmethod def _term_to_secret_id(term): @@ -226,6 +348,13 @@ class TSSClient(object): except ValueError: raise AnsibleOptionsError("Secret ID must be an integer") + @staticmethod + def _term_to_folder_id(term): + try: + return int(term) + except ValueError: + raise AnsibleOptionsError("Folder ID must be an integer") + class TSSClientV0(TSSClient): def __init__(self, **server_parameters): @@ -294,6 +423,20 @@ class LookupModule(LookupBase): ) try: - return [tss.get_secret(term) for term in terms] + if self.get_option("fetch_secret_ids_from_folder"): + if HAS_DELINEA_SS_SDK: + return [tss.get_secret_ids_by_folderid(term) for term in terms] + else: + raise AnsibleError("latest python-tss-sdk must be installed to use this plugin") + else: + return [ + tss.get_secret( + term, + self.get_option("secret_path"), + self.get_option("fetch_attachments"), + self.get_option("file_download_path"), + ) + for term in terms + ] except SecretServerError as error: raise AnsibleError("Secret Server lookup failure: %s" % error.message) |