Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for core changes: stop stripping Unsafe, adapt adapter to config manager changes #416

Merged
merged 18 commits into from
Dec 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ansible-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
name: Sanity (Ⓐ${{ matrix.ansible }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
Expand Down Expand Up @@ -106,8 +107,7 @@ jobs:
runs-on: ${{ matrix.runner }}
name: Units (Ⓐ${{ matrix.ansible }})
strategy:
# As soon as the first unit test fails, cancel the others to free up the CI queue
fail-fast: true
fail-fast: false
matrix:
runner:
- ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions changelogs/fragments/416-core-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
major_changes:
- requirements - the ``requests`` package which is required by ``hvac`` now has a more restrictive range for this collection in certain use cases due to breaking security changes in ``ansible-core`` that were backported (https://github.com/ansible-collections/community.hashi_vault/pull/416).

trivial:
- We previously sometimes used options, especially in plugins, as a sort of internal information store for things that needed to be passed between methods, or we overwrote some options internally with values that did not match their documentation-defined type. A breaking change in core that validates and converts types to match config manager definitions has broken this usage. Code has been updated to avoid that, and/or change documented types (https://github.com/ansible-collections/community.hashi_vault/pull/416).
- The docker SDK version used in localenv setup has been properly constrained (https://github.com/ansible-collections/community.hashi_vault/pull/416/commits/905ac5da0778ddc236e346dd55578837a546090e).
1 change: 1 addition & 0 deletions docs/docsite/rst/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Other requirements

* ``boto3`` (only if loading credentials from a boto session, for example using an AWS profile or IAM role credentials)
* ``azure-identity`` (only if using a service principal or managed identity)
* ``requests`` — with ``requests>=2.28,<2.29``, setting certain options (``token``, ``namespace``) to values that come from lookups will raise an exception, do to Ansible's marking of the values as "unsafe" for templating. We recommend using ``requests>=2.29``, which won't work with Python 3.6.

Retrying failed requests
========================
Expand Down
5 changes: 3 additions & 2 deletions plugins/lookup/hashi_vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,14 @@ def field_ops(self):
field = s_f[1]
else:
field = None
self.set_option('secret_field', field)

self._secret_field = field

def get(self):
'''gets a secret. should always return a list'''

field = self._secret_field
secret = self.get_option('secret')
field = self.get_option('secret_field')
return_as = self.get_option('return_format')

try:
Expand Down
2 changes: 1 addition & 1 deletion plugins/module_utils/_auth_method_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def validate(self):
raise HashiVaultValueError("No Vault Token specified or discovered.")

def authenticate(self, client, use_token=True, lookup_self=False):
token = self._stringify(self._options.get_option('token'))
token = self._options.get_option('token')
validate = self._options.get_option_default('token_validate')

response = None
Expand Down
8 changes: 4 additions & 4 deletions plugins/module_utils/_connection_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ def get_hvac_connection_options(self):

# validate_certs is only used to optionally change the value of ca_cert
def _filter(k, v):
return v is not None and k != 'validate_certs'
return v is not None and k not in ('validate_certs', 'ca_cert')

# our transformed ca_cert value will become the verify parameter for the hvac client
hvopts = self._options.get_filtered_options(_filter, *self.OPTIONS)
hvopts['verify'] = hvopts.pop('ca_cert')
hvopts['verify'] = self._conopt_verify

retry_action = hvopts.pop('retry_action')
if 'retries' in hvopts:
Expand Down Expand Up @@ -255,6 +255,6 @@ def _boolean_or_cacert(self):
validate_certs = True

if not (validate_certs and ca_cert):
self._options.set_option('ca_cert', validate_certs)
self._conopt_verify = validate_certs
else:
self._options.set_option('ca_cert', to_text(ca_cert, errors='surrogate_or_strict'))
self._conopt_verify = to_text(ca_cert, errors='surrogate_or_strict')
50 changes: 1 addition & 49 deletions plugins/module_utils/_hashi_vault_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,54 +25,18 @@
HAS_HVAC = False


def _stringify(input):
'''
This method is primarily used to Un-Unsafe values that come from Ansible.
We want to remove the Unsafe context so that libraries don't get confused
by the values.
'''

# Since this is a module_util, and will be used by both plugins and modules,
# we cannot import the AnsibleUnsafe* types, because they are controller-only.
# However, they subclass the native types, so we can check for that.

# bytes is the only consistent type to check against in both py2 and py3
if isinstance(input, bytes):
# seems redundant, but this will give us a regular bytes object even
# when the input is AnsibleUnsafeBytes
return bytes(input)
else:
# instead of checking for py2 vs. py3 to cast to str or unicode,
# let's get the type from the literal.
return type(u'')(input)


class HashiVaultValueError(ValueError):
'''Use in common code to raise an Exception that can be turned into AnsibleError or used to fail_json()'''


class HashiVaultHelper():

STRINGIFY_CANDIDATES = set([
'token', # Token will end up in a header, requests requires headers to be str or bytes,
# and newer versions of requests stopped converting automatically. Because our
# token could have been passed in from a previous lookup call, it could be one
# of the AnsibleUnsafe types instead, causing a failure. Tokens should always
# be strings, so we will convert them.
'namespace', # namespace is also set in a header
])

def __init__(self):
# TODO move hvac checking here?
pass

@staticmethod
def _stringify(input):
return _stringify(input)

def get_vault_client(
self,
hashi_vault_logout_inferred_token=True, hashi_vault_revoke_on_logout=False, hashi_vault_stringify_args=True,
hashi_vault_logout_inferred_token=True, hashi_vault_revoke_on_logout=False,
**kwargs
):
'''
Expand All @@ -84,16 +48,8 @@ def get_vault_client(

:param hashi_vault_revoke_on_logout: if True revokes any current token on logout. Only used if a logout is performed. Not recommended.
:type hashi_vault_revoke_on_logout: bool

:param hashi_vault_stringify_args: if True converts a specific set of defined kwargs to a string type.
:type hashi_vault_stringify_args: bool
'''

if hashi_vault_stringify_args:
for key in kwargs.keys():
if key in self.STRINGIFY_CANDIDATES:
kwargs[key] = self._stringify(kwargs[key])

client = hvac.Client(**kwargs)

# logout to prevent accidental use of inferred tokens
Expand Down Expand Up @@ -296,7 +252,3 @@ def warn(self, message):

def deprecate(self, message, version=None, date=None, collection_name=None):
self._deprecator(message, version=version, date=date, collection_name=collection_name)

@staticmethod
def _stringify(input):
return _stringify(input)
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,8 @@ hvac >= 1.2.1 ; python_version >= '3.6'
# urllib3
# these should be satisfied naturally by the requests versions required by hvac anyway
urllib3 >= 1.15 ; python_version >= '3.6' # we need raise_on_status for retry support to raise the correct exceptions https://github.com/urllib3/urllib3/blob/main/CHANGES.rst#115-2016-04-06

# requests
# https://github.com/psf/requests/pull/6356
requests >= 2.29 ; python_version >= '3.7'
requests < 2.28 ; python_version < '3.7'
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
docker >= 5.0.0 ; python_version >= '3.6'
# https://github.com/docker/docker-py/issues/3194#issuecomment-1849016391
# Must use docker SDK for Python < 7
docker >= 5.0.0,<7 ; python_version >= '3.6'
64 changes: 64 additions & 0 deletions tests/unit/constraints.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
coverage >= 4.2, < 5.0.0, != 4.3.2 ; python_version <= '3.7' # features in 4.2+ required, avoid known bug in 4.3.2 on python 2.6, coverage 5.0+ incompatible
coverage >= 4.5.4, < 5.0.0 ; python_version > '3.7' # coverage had a bug in < 4.5.4 that would cause unit tests to hang in Python 3.8, coverage 5.0+ incompatible
cryptography < 2.2 ; python_version < '2.7' # cryptography 2.2 drops support for python 2.6
deepdiff < 4.0.0 ; python_version < '3' # deepdiff 4.0.0 and later require python 3
jinja2 < 2.11 ; python_version < '2.7' # jinja2 2.11 and later require python 2.7 or later
urllib3 < 1.24 ; python_version < '2.7' # urllib3 1.24 and later require python 2.7 or later
pywinrm >= 0.3.0 # message encryption support
sphinx < 1.6 ; python_version < '2.7' # sphinx 1.6 and later require python 2.7 or later
sphinx < 1.8 ; python_version >= '2.7' # sphinx 1.8 and later are currently incompatible with rstcheck 3.3
pygments >= 2.4.0 # Pygments 2.4.0 includes bugfixes for YAML and YAML+Jinja lexers
wheel < 0.30.0 ; python_version < '2.7' # wheel 0.30.0 and later require python 2.7 or later
yamllint != 1.8.0, < 1.14.0 ; python_version < '2.7' # yamllint 1.8.0 and 1.14.0+ require python 2.7+
pycrypto >= 2.6 # Need features found in 2.6 and greater
ncclient >= 0.5.2 # Need features added in 0.5.2 and greater
idna < 2.6, >= 2.5 # linode requires idna < 2.9, >= 2.5, requests requires idna < 2.6, but cryptography will cause the latest version to be installed instead
paramiko < 2.4.0 ; python_version < '2.7' # paramiko 2.4.0 drops support for python 2.6
pytest < 3.3.0 ; python_version < '2.7' # pytest 3.3.0 drops support for python 2.6
pytest < 5.0.0 ; python_version == '2.7' # pytest 5.0.0 and later will no longer support python 2.7
pytest-forked < 1.0.2 ; python_version < '2.7' # pytest-forked 1.0.2 and later require python 2.7 or later
pytest-forked >= 1.0.2 ; python_version >= '2.7' # pytest-forked before 1.0.2 does not work with pytest 4.2.0+ (which requires python 2.7+)
ntlm-auth >= 1.3.0 # message encryption support using cryptography
requests < 2.20.0 ; python_version < '2.7' # requests 2.20.0 drops support for python 2.6
requests-ntlm >= 1.1.0 # message encryption support
requests-credssp >= 0.1.0 # message encryption support
voluptuous >= 0.11.0 # Schema recursion via Self
openshift >= 0.6.2, < 0.9.0 # merge_type support
virtualenv < 16.0.0 ; python_version < '2.7' # virtualenv 16.0.0 and later require python 2.7 or later
pathspec < 0.6.0 ; python_version < '2.7' # pathspec 0.6.0 and later require python 2.7 or later
pyopenssl < 18.0.0 ; python_version < '2.7' # pyOpenSSL 18.0.0 and later require python 2.7 or later
pyfmg == 0.6.1 # newer versions do not pass current unit tests
pyyaml < 5.1 ; python_version < '2.7' # pyyaml 5.1 and later require python 2.7 or later
pycparser < 2.19 ; python_version < '2.7' # pycparser 2.19 and later require python 2.7 or later
mock >= 2.0.0 # needed for features backported from Python 3.6 unittest.mock (assert_called, assert_called_once...)
pytest-mock >= 1.4.0 # needed for mock_use_standalone_module pytest option
xmltodict < 0.12.0 ; python_version < '2.7' # xmltodict 0.12.0 and later require python 2.7 or later
lxml < 4.3.0 ; python_version < '2.7' # lxml 4.3.0 and later require python 2.7 or later
pyvmomi < 6.0.0 ; python_version < '2.7' # pyvmomi 6.0.0 and later require python 2.7 or later
pyone == 1.1.9 # newer versions do not pass current integration tests
boto3 < 1.11 ; python_version < '2.7' # boto3 1.11 drops Python 2.6 support
botocore >= 1.10.0, < 1.14 ; python_version < '2.7' # adds support for the following AWS services: secretsmanager, fms, and acm-pca; botocore 1.14 drops Python 2.6 support
botocore >= 1.10.0 ; python_version >= '2.7' # adds support for the following AWS services: secretsmanager, fms, and acm-pca
setuptools < 45 ; python_version <= '2.7' # setuptools 45 and later require python 3.5 or later
cffi >= 1.14.2, != 1.14.3 # Yanked version which older versions of pip will still install:

# freeze pylint and its requirements for consistent test results
astroid == 2.2.5
isort == 4.3.15
lazy-object-proxy == 1.3.1
mccabe == 0.6.1
pylint == 2.3.1
typed-ast == 1.4.0 # 1.4.0 is required to compile on Python 3.8
wrapt == 1.11.1

# hvac
hvac >= 1.2.1 ; python_version >= '3.6'

# urllib3
# these should be satisfied naturally by the requests versions required by hvac anyway
urllib3 >= 1.15 ; python_version >= '3.6' # we need raise_on_status for retry support to raise the correct exceptions https://github.com/urllib3/urllib3/blob/main/CHANGES.rst#115-2016-04-06

# requests
# https://github.com/psf/requests/pull/6356
requests >= 2.29 ; python_version >= '3.7'
requests < 2.28 ; python_version < '3.7'
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@

import pytest

from ansible_collections.community.hashi_vault.tests.unit.compat import mock

from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import (
from ......plugins.module_utils._hashi_vault_common import (
HashiVaultAuthMethodBase,
HashiVaultOptionGroupBase,
HashiVaultValueError,
_stringify,
)


Expand Down Expand Up @@ -75,12 +72,3 @@ def test_deprecate_callback(self, auth_base, deprecator, version, date, collecti
auth_base.deprecate(msg, version, date, collection_name)

deprecator.assert_called_once_with(msg, version=version, date=date, collection_name=collection_name)

def test_has_stringify(self, auth_base):
v = 'X'
wrapper = mock.Mock(wraps=_stringify)
with mock.patch('ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common._stringify', wrapper):
r = auth_base._stringify(v)

wrapper.assert_called_once_with(v)
assert r == v
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest

from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultOptionAdapter
from ......plugins.module_utils._hashi_vault_common import HashiVaultOptionAdapter


SAMPLE_DICT = {
Expand Down Expand Up @@ -103,44 +103,80 @@ def test_get_option_default_missing_returns_default(self, adapter, option):
def test_has_option(self, adapter, option, expected):
assert adapter.has_option(option) == expected

@pytest.mark.parametrize('value', ['__VALUE'])
@pytest.mark.parametrize('option', (SAMPLE_KEYS + MISSING_KEYS))
def test_set_option(self, adapter, option, value, sample_dict):
@pytest.mark.parametrize('option', SAMPLE_KEYS)
def test_set_option_existing(self, adapter, option, sample_dict):
value = type(sample_dict.get(option, ""))()
adapter.set_option(option, value)

# first check the underlying data, then ensure the adapter refelcts the change too
assert sample_dict[option] == value
assert adapter.get_option(option) == value

@pytest.mark.parametrize('option', MISSING_KEYS)
def test_set_option_missing(self, request, adapter, option, sample_dict):
value = MARKER

for mark in request.node.own_markers:
if mark.name == 'option_adapter_raise_on_missing':
from ansible.errors import AnsibleError
with pytest.raises(AnsibleError, match=rf"^Requested entry.*?setting: {option}.*?was not defined in configuration"):
adapter.set_option(option, value)
break
else:
adapter.set_option(option, value)
assert sample_dict[option] == value
assert adapter.get_option(option) == value

@pytest.mark.parametrize('default', [MARKER])
@pytest.mark.parametrize('option,expected', [(o, SAMPLE_DICT[o]) for o in SAMPLE_KEYS] + [(o, MARKER) for o in MISSING_KEYS])
def test_set_option_default(self, adapter, option, default, expected, sample_dict):
@pytest.mark.parametrize('option,expected', [(o, SAMPLE_DICT[o]) for o in SAMPLE_KEYS])
def test_set_option_default_existing(self, adapter, option, default, expected, sample_dict):
value = adapter.set_option_default(option, default)

# check return data, underlying data structure, and adapter retrieval
assert value == expected
assert sample_dict[option] == expected
assert adapter.get_option(option) == expected

@pytest.mark.parametrize('options', [[SAMPLE_KEYS[0], MISSING_KEYS[0]]])
def test_set_options(self, adapter, options, sample_dict):
update = dict([(o, '__VALUE_%i' % i) for i, o in enumerate(options)])
@pytest.mark.parametrize('default', [MARKER])
@pytest.mark.parametrize('option,expected', [(o, MARKER) for o in MISSING_KEYS])
def test_set_option_default_missing(self, request, adapter, option, default, expected, sample_dict):
for mark in request.node.own_markers:
if mark.name == 'option_adapter_raise_on_missing':
from ansible.errors import AnsibleError
with pytest.raises(AnsibleError, match=rf"^Requested entry.*?setting: {option}.*?was not defined in configuration"):
adapter.set_option_default(option, default)
break
else:
value = adapter.set_option_default(option, default)
# check return data, underlying data structure, and adapter retrieval
assert value == expected
assert sample_dict[option] == expected
assert adapter.get_option(option) == expected

adapter.set_options(**update)
@pytest.mark.parametrize('options', [[SAMPLE_KEYS[0], MISSING_KEYS[0]]])
def test_set_options(self, request, adapter, options, sample_dict):
update = dict([(o, type(sample_dict.get(o, ""))(i)) for i, o in enumerate(options)])

for mark in request.node.own_markers:
if mark.name == 'option_adapter_raise_on_missing':
from ansible.errors import AnsibleError
with pytest.raises(AnsibleError, match=r"^Requested entry.*?setting:.*?was not defined in configuration"):
adapter.set_options(**update)
break
else:
adapter.set_options(**update)
for k in MISSING_KEYS:
if k in update:
assert sample_dict[k] == update[k]
assert adapter.get_option(k) == update[k]
else:
assert k not in sample_dict
assert not adapter.has_option(k)

for k in SAMPLE_KEYS:
expected = update[k] if k in update else SAMPLE_DICT[k]
assert sample_dict[k] == expected
assert adapter.get_option(k) == expected

for k in MISSING_KEYS:
if k in update:
assert sample_dict[k] == update[k]
assert adapter.get_option(k) == update[k]
else:
assert k not in sample_dict
assert not adapter.has_option(k)

@pytest.mark.parametrize('options', [[SAMPLE_KEYS[0], MISSING_KEYS[0]]])
def test_get_options_mixed(self, adapter, options):
with pytest.raises(KeyError):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def test_boolean_or_cacert(self, connection_options, predefined_options, adapter
with mock.patch.dict(os.environ, envpatch):
connection_options._boolean_or_cacert()

assert predefined_options['ca_cert'] == expected
assert connection_options._conopt_verify == expected

# _process_option_proxies
# proxies can be specified as a dictionary where key is protocol/scheme
Expand Down Expand Up @@ -248,7 +248,7 @@ def test_get_hvac_connection_options(

# these should always be returned
assert 'url' in opts and opts['url'] == predefined_options['url']
assert 'verify' in opts and opts['verify'] == predefined_options['ca_cert']
assert 'verify' in opts and opts['verify'] == connection_options._conopt_verify

# these are optional
assert 'proxies' not in opts or opts['proxies'] == predefined_options['proxies']
Expand Down