diff --git a/changelog/43287.added b/changelog/43287.added new file mode 100644 index 00000000000..90b47e65d44 --- /dev/null +++ b/changelog/43287.added @@ -0,0 +1 @@ +Added pillar templating to vault policies diff --git a/salt/modules/vault.py b/salt/modules/vault.py index 3e7168b0f87..2519fdd9221 100644 --- a/salt/modules/vault.py +++ b/salt/modules/vault.py @@ -130,6 +130,12 @@ Functions to interact with Hashicorp Vault. values, eg ``my-policies/{grains[os]}``. ``{minion}`` is shorthand for ``grains[id]``, eg ``saltstack/minion/{minion}``. + .. versionadded:: 3006 + + Policies can be templated with pillar values as well: ``salt_role_{pillar[roles]}`` + Make sure to only reference pillars that are not sourced from Vault since the latter + ones might be unavailable during policy rendering. + .. important:: See :ref:`Is Targeting using Grain Data Secure? @@ -160,6 +166,29 @@ Functions to interact with Hashicorp Vault. Optional. If policies is not configured, ``saltstack/minions`` and ``saltstack/{minion}`` are used as defaults. + policies_refresh_pillar + Whether to refresh the pillar data when rendering templated policies. + When unset (=null/None), will only refresh when the cached data + is unavailable, boolean values force one behavior always. + + .. note:: + + Using cached pillar data only (policies_refresh_pillar=False) + might cause the policies to be out of sync. If there is no cached pillar + data available for the minion, pillar templates will fail to render at all. + + If you use pillar values for templating policies and do not disable + refreshing pillar data, make sure the relevant values are not sourced + from Vault (ext_pillar, sdb) or from a pillar sls file that uses the vault + execution module. Although this will often work when cached pillar data is + available, if the master needs to compile the pillar data during policy rendering, + all Vault modules will be broken to prevent an infinite loop. + + policies_cache_time + Policy computation can be heavy in case pillar data is used in templated policies and + it has not been cached. Therefore, a short-lived cache specifically for rendered policies + is used. This specifies the expiration timeout in seconds. Defaults to 60. + keys List of keys to use to unseal vault server with the vault.unseal runner. diff --git a/salt/pillar/vault.py b/salt/pillar/vault.py index d4e02748db8..f632c74a519 100644 --- a/salt/pillar/vault.py +++ b/salt/pillar/vault.py @@ -170,10 +170,16 @@ def ext_pillar( nesting_key=None, merge_strategy=None, merge_lists=None, + extra_minion_data=None, ): """ Get pillar data from Vault for the configuration ``conf``. """ + extra_minion_data = extra_minion_data or {} + if extra_minion_data.get("_vault_runner_is_compiling_pillar_templates"): + # Disable vault ext_pillar while compiling pillar for vault policy templates + return {} + comps = conf.split() paths = [comp for comp in comps if comp.startswith("path=")] diff --git a/salt/runners/vault.py b/salt/runners/vault.py index fa88b69f7b7..aed276b42ce 100644 --- a/salt/runners/vault.py +++ b/salt/runners/vault.py @@ -8,14 +8,20 @@ documented in the execution module docs. """ import base64 +import copy import json import logging import time +from collections.abc import Mapping import requests +import salt.cache import salt.crypt import salt.exceptions +import salt.pillar +from salt.defaults import NOT_SET +from salt.exceptions import SaltRunnerError log = logging.getLogger(__name__) @@ -62,6 +68,8 @@ def generate_token( if not allow_minion_override or ttl is None: ttl = config["auth"].get("ttl", None) storage_type = config["auth"].get("token_backend", "session") + policies_refresh_pillar = config.get("policies_refresh_pillar", None) + policies_cache_time = config.get("policies_cache_time", 60) if config["auth"]["method"] == "approle": if _selftoken_expired(): @@ -92,7 +100,12 @@ def generate_token( "saltstack-user": globals().get("__user__", ""), } payload = { - "policies": _get_policies(minion_id, config), + "policies": _get_policies_cached( + minion_id, + config, + refresh_pillar=policies_refresh_pillar, + expire=policies_cache_time, + ), "num_uses": uses, "meta": audit_data, } @@ -160,12 +173,24 @@ def unseal(): return False -def show_policies(minion_id): +def show_policies(minion_id, refresh_pillar=NOT_SET, expire=None): """ - Show the Vault policies that are applied to tokens for the given minion + Show the Vault policies that are applied to tokens for the given minion. minion_id - The minions id + The minion's id. + + refresh_pillar + Whether to refresh the pillar data when rendering templated policies. + None will only refresh when the cached data is unavailable, boolean values + force one behavior always. + Defaults to config value ``policies_refresh_pillar`` or None. + + expire + Policy computation can be heavy in case pillar data is used in templated policies and + it has not been cached. Therefore, a short-lived cache specifically for rendered policies + is used. This specifies the expiration timeout in seconds. + Defaults to config value ``policies_cache_time`` or 60. CLI Example: @@ -173,8 +198,13 @@ def show_policies(minion_id): salt-run vault.show_policies myminion """ - config = __opts__["vault"] - return _get_policies(minion_id, config) + config = __opts__.get("vault", {}) + if refresh_pillar == NOT_SET: + refresh_pillar = config.get("policies_refresh_pillar") + expire = expire if expire is not None else config.get("policies_cache_time", 60) + return _get_policies_cached( + minion_id, config, refresh_pillar=refresh_pillar, expire=expire + ) def _validate_signature(minion_id, signature, impersonated_by_master): @@ -197,15 +227,18 @@ def _validate_signature(minion_id, signature, impersonated_by_master): log.trace("Signature ok") -def _get_policies(minion_id, config): +# **kwargs because salt.cache.Cache does not pop "expire" from kwargs +def _get_policies( + minion_id, config, refresh_pillar=None, **kwargs +): # pylint: disable=unused-argument """ Get the policies that should be applied to a token for minion_id """ - _, grains, _ = salt.utils.minions.get_minion_data(minion_id, __opts__) + grains, pillar = _get_minion_data(minion_id, refresh_pillar) policy_patterns = config.get( "policies", ["saltstack/minion/{minion}", "saltstack/minions"] ) - mappings = {"minion": minion_id, "grains": grains or {}} + mappings = {"minion": minion_id, "grains": grains, "pillar": pillar} policies = [] for pattern in policy_patterns: @@ -217,12 +250,84 @@ def _get_policies(minion_id, config): expanded_pattern.format(**mappings).lower() # Vault requirement ) except KeyError: - log.warning("Could not resolve policy pattern %s", pattern) + log.warning( + "Could not resolve policy pattern %s for minion %s", pattern, minion_id + ) log.debug("%s policies: %s", minion_id, policies) return policies +def _get_policies_cached(minion_id, config, refresh_pillar=None, expire=60): + # expiration of 0 disables cache + if not expire: + return _get_policies(minion_id, config, refresh_pillar=refresh_pillar) + cbank = f"minions/{minion_id}/vault" + ckey = "policies" + cache = salt.cache.factory(__opts__) + policies = cache.cache( + cbank, + ckey, + _get_policies, + expire=expire, + minion_id=minion_id, + config=config, + refresh_pillar=refresh_pillar, + ) + if not isinstance(policies, list): + log.warning("Cached vault policies were not formed as a list. Refreshing.") + cache.flush(cbank, ckey) + policies = cache.cache( + cbank, + ckey, + _get_policies, + expire=expire, + minion_id=minion_id, + config=config, + refresh_pillar=refresh_pillar, + ) + return policies + + +def _get_minion_data(minion_id, refresh_pillar=None): + _, grains, pillar = salt.utils.minions.get_minion_data(minion_id, __opts__) + + if grains is None: + grains = {} + # To properly refresh minion grains, something like this could be used: + # __salt__["salt.execute"](minion_id, "saltutil.refresh_grains", refresh_pillar=False) + # This is deliberately not done since grains should not be used to target + # secrets anyways. + + # salt.utils.minions.get_minion_data only returns data from cache or None. + # To make sure the correct policies are available, the pillar needs to be + # refreshed. This can cause an infinite loop if the pillar data itself + # depends on the vault execution module, which relies on this function. + # By default, only refresh when necessary. Boolean values force one way. + if refresh_pillar is True or (refresh_pillar is None and pillar is None): + if __opts__.get("_vault_runner_is_compiling_pillar_templates"): + raise SaltRunnerError( + "Cyclic dependency detected while refreshing pillar for vault policy templating. " + "This is caused by some pillar value relying on the vault execution module. " + "Either remove the dependency from your pillar, disable refreshing pillar data " + "for policy templating or do not use pillar values in policy templates." + ) + local_opts = copy.deepcopy(__opts__) + # Relying on opts for ext_pillars does not work properly (only the first one runs + # correctly). + extra_minion_data = {"_vault_runner_is_compiling_pillar_templates": True} + local_opts.update(extra_minion_data) + pillar = LazyPillar( + local_opts, grains, minion_id, extra_minion_data=extra_minion_data + ) + elif pillar is None: + # Make sure pillar is a dict. Necessary because a check on LazyPillar would + # refresh it unconditionally (even when no pillar values are used) + pillar = {} + + return grains, pillar + + def _selftoken_expired(): """ Validate the current token exists and is still valid @@ -256,3 +361,41 @@ def _get_token_create_url(config): auth_path = "/v1/auth/token/create" base_url = config["url"] return "/".join(x.strip("/") for x in (base_url, auth_path, role_name) if x) + + +class LazyPillar(Mapping): + """ + Simulates a pillar dictionary. Only compiles the pillar + once an item is requested. + """ + + def __init__(self, opts, grains, minion_id, extra_minion_data=None): + self.opts = opts + self.grains = grains + self.minion_id = minion_id + self.extra_minion_data = extra_minion_data or {} + self._pillar = None + + def _load(self): + log.info("Refreshing pillar for vault templating.") + self._pillar = salt.pillar.get_pillar( + self.opts, + self.grains, + self.minion_id, + extra_minion_data=self.extra_minion_data, + ).compile_pillar() + + def __getitem__(self, key): + if self._pillar is None: + self._load() + return self._pillar[key] + + def __iter__(self): + if self._pillar is None: + self._load() + yield from self._pillar + + def __len__(self): + if self._pillar is None: + self._load() + return len(self._pillar) diff --git a/salt/sdb/vault.py b/salt/sdb/vault.py index 08360e2d84f..60d594fd9c8 100644 --- a/salt/sdb/vault.py +++ b/salt/sdb/vault.py @@ -80,6 +80,8 @@ def get(key, profile=None): """ Get a value from the vault service """ + if __opts__.get("_vault_runner_is_compiling_pillar_templates"): + return None if "?" in key: path, key = key.split("?") else: diff --git a/tests/pytests/integration/runners/test_vault.py b/tests/pytests/integration/runners/test_vault.py new file mode 100644 index 00000000000..311c51ac9d2 --- /dev/null +++ b/tests/pytests/integration/runners/test_vault.py @@ -0,0 +1,285 @@ +""" +Tests for the Vault runner +""" + +import logging +import shutil + +import pytest + +log = logging.getLogger(__name__) + + +pytestmark = [ + pytest.mark.slow_test, +] + + +@pytest.fixture(scope="module") +def pillar_state_tree(tmp_path_factory): + _pillar_state_tree = tmp_path_factory.mktemp("pillar") + try: + yield _pillar_state_tree + finally: + shutil.rmtree(str(_pillar_state_tree), ignore_errors=True) + + +@pytest.fixture(scope="module") +def pillar_salt_master(salt_factories, pillar_state_tree): + config_defaults = { + "pillar_roots": {"base": [str(pillar_state_tree)]}, + "open_mode": True, + "ext_pillar": [{"vault": "path=does/not/matter"}], + "sdbvault": { + "driver": "vault", + }, + "vault": { + "auth": {"token": "testsecret"}, + "policies": [ + "salt_minion", + "salt_minion_{minion}", + "salt_role_{pillar[roles]}", + "salt_unsafe_{grains[foo]}", + ], + }, + } + factory = salt_factories.salt_master_daemon( + "vault-pillarpolicy-functional-master", defaults=config_defaults + ) + with factory.started(): + yield factory + + +@pytest.fixture(scope="module") +def pillar_salt_minion(pillar_salt_master): + assert pillar_salt_master.is_running() + factory = pillar_salt_master.salt_minion_daemon( + "vault-pillarpolicy-functional-minion-1", + defaults={"open_mode": True, "grains": {"foo": "bar"}}, + ) + with factory.started(): + # Sync All + salt_call_cli = factory.salt_call_cli() + ret = salt_call_cli.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + yield factory + + +@pytest.fixture(scope="module") +def pillar_salt_run_cli(pillar_salt_master): + return pillar_salt_master.salt_run_cli() + + +@pytest.fixture(scope="module") +def pillar_salt_call_cli(pillar_salt_minion): + return pillar_salt_minion.salt_call_cli() + + +@pytest.fixture() +def pillar_policy_tree(pillar_state_tree, pillar_salt_minion, pillar_salt_call_cli): + top_file = """ + base: + '{}': + - roles + - sdb_loop + """.format( + pillar_salt_minion.id + ) + roles_pillar = """ + roles: + - minion + - web + """ + sdb_loop_pillar = """ + foo: {{ salt["sdb.get"]("sdb://sdbvault/does/not/matter/val") }} + """ + top_tempfile = pytest.helpers.temp_file("top.sls", top_file, pillar_state_tree) + roles_tempfile = pytest.helpers.temp_file( + "roles.sls", roles_pillar, pillar_state_tree + ) + sdb_loop_tempfile = pytest.helpers.temp_file( + "sdb_loop.sls", sdb_loop_pillar, pillar_state_tree + ) + + with top_tempfile, roles_tempfile, sdb_loop_tempfile: + yield + + +@pytest.fixture() +def pillar_loop( + pillar_state_tree, pillar_policy_tree, pillar_salt_minion, pillar_salt_call_cli +): + top_file = """ + base: + '{}': + - roles + - sdb_loop + - exe_loop + """.format( + pillar_salt_minion.id + ) + exe_loop_pillar = r""" + bar: {{ salt["vault.read_secret"]("does/not/matter") }} + """ + top_tempfile = pytest.helpers.temp_file("top.sls", top_file, pillar_state_tree) + exe_loop_tempfile = pytest.helpers.temp_file( + "exe_loop.sls", exe_loop_pillar, pillar_state_tree + ) + + with top_tempfile, exe_loop_tempfile: + yield + + +class TestVaultPillarPolicyTemplatesWithoutCache: + @pytest.fixture() + def minion_data_cache_absent( + self, pillar_salt_run_cli, pillar_salt_minion, pillar_policy_tree + ): + ret = pillar_salt_run_cli.run( + "cache.flush", f"minions/{pillar_salt_minion.id}", "data" + ) + assert ret.returncode == 0 + cached = pillar_salt_run_cli.run( + "cache.fetch", f"minions/{pillar_salt_minion.id}", "data" + ) + assert cached.returncode == 0 + assert not cached.data + yield + + def test_show_policies( + self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_absent + ): + """ + Test that pillar data is refreshed correctly before rendering policies when necessary. + This test includes the prevention of loop exceptions by sdb/ext_pillar modules + This refresh does not include grains and pillar data targeted by these grains (unsafe anyways!). + """ + ret = pillar_salt_run_cli.run( + "vault.show_policies", pillar_salt_minion.id, expire=0 + ) + assert ret.data == [ + "salt_minion", + f"salt_minion_{pillar_salt_minion.id}", + "salt_role_minion", + "salt_role_web", + ] + assert "Pillar render error: Failed to load ext_pillar vault" not in ret.stderr + assert "Pillar render error: Rendering SLS 'sdb_loop' failed" not in ret.stderr + + def test_show_policies_uncached_data_no_pillar_refresh( + self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_absent + ): + """ + Test that pillar is not refreshed when explicitly disabled + """ + ret = pillar_salt_run_cli.run( + "vault.show_policies", pillar_salt_minion.id, refresh_pillar=False, expire=0 + ) + assert ret.data == ["salt_minion", f"salt_minion_{pillar_salt_minion.id}"] + + def test_policy_compilation_prevents_loop( + self, + pillar_salt_run_cli, + pillar_salt_minion, + pillar_loop, + minion_data_cache_absent, + ): + """ + Test that the runner prevents a recursive cycle from happening + """ + ret = pillar_salt_run_cli.run( + "vault.show_policies", pillar_salt_minion.id, refresh_pillar=True, expire=0 + ) + assert ret.data == [ + "salt_minion", + f"salt_minion_{pillar_salt_minion.id}", + "salt_role_minion", + "salt_role_web", + ] + assert "Pillar render error: Rendering SLS 'exe_loop' failed" in ret.stderr + + +class TestVaultPillarPolicyTemplatesWithCache: + @pytest.fixture() + def minion_data_cache_present( + self, + pillar_salt_call_cli, + pillar_salt_run_cli, + pillar_salt_minion, + pillar_policy_tree, + ): + ret = pillar_salt_call_cli.run("saltutil.refresh_pillar", wait=True) + assert ret.returncode == 0 + assert ret.data is True + yield + + @pytest.fixture() + def minion_data_cache_outdated( + self, + minion_data_cache_present, + pillar_salt_run_cli, + pillar_salt_call_cli, + pillar_policy_tree, + pillar_state_tree, + pillar_salt_minion, + ): + roles_pillar = """ + roles: + - minion + - web + - fresh + """ + roles_tempfile = pytest.helpers.temp_file( + "roles.sls", roles_pillar, pillar_state_tree + ) + cached = pillar_salt_run_cli.run( + "cache.fetch", f"minions/{pillar_salt_minion.id}", "data" + ) + assert cached.returncode == 0 + assert cached.data + assert "pillar" in cached.data + assert "grains" in cached.data + assert "foo" in cached.data["grains"] + assert "roles" in cached.data["pillar"] + assert ["minion", "web"] == cached.data["pillar"]["roles"] + with roles_tempfile: + yield + + def test_show_policies_cached_data_no_pillar_refresh( + self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_outdated + ): + """ + Test that pillar data from cache is used when it is available + """ + ret = pillar_salt_run_cli.run( + "vault.show_policies", pillar_salt_minion.id, expire=0 + ) + assert ret.data == [ + "salt_minion", + f"salt_minion_{pillar_salt_minion.id}", + "salt_role_minion", + "salt_role_web", + "salt_unsafe_bar", + ] + + def test_show_policies_refresh_pillar( + self, pillar_salt_run_cli, pillar_salt_minion, minion_data_cache_outdated + ): + """ + Test that pillar data is always refreshed when requested. + This test includes the prevention of loops by sdb/ext_pillar modules + This refresh does not include grains and pillar data targeted by these grains (unsafe anyways!). + """ + ret = pillar_salt_run_cli.run( + "vault.show_policies", pillar_salt_minion.id, refresh_pillar=True, expire=0 + ) + assert ret.data == [ + "salt_minion", + f"salt_minion_{pillar_salt_minion.id}", + "salt_role_minion", + "salt_role_web", + "salt_role_fresh", + "salt_unsafe_bar", + ] + assert "Pillar render error: Failed to load ext_pillar vault" not in ret.stderr + assert "Pillar render error: Rendering SLS 'sdb_loop' failed" not in ret.stderr diff --git a/tests/pytests/unit/pillar/test_vault.py b/tests/pytests/unit/pillar/test_vault.py index 23c43f5b5a9..92b4bcd8a6a 100644 --- a/tests/pytests/unit/pillar/test_vault.py +++ b/tests/pytests/unit/pillar/test_vault.py @@ -182,3 +182,18 @@ def test_ext_pillar_merging(is_v2_false): merge_lists=False, ) assert ext_pillar == expected + + +def text_ext_pillar_disabled_during_policy_pillar_rendering(): + mock_version = Mock() + mock_vault = Mock() + extra = {"_vault_runner_is_compiling_pillar_templates": True} + + with patch.dict( + vault.__utils__, {"vault.make_request": mock_vault, "vault.is_v2": mock_version} + ): + assert {} == vault.ext_pillar( + "test-minion", {}, conf="path=secret/path", extra_minion_data=extra + ) + assert mock_version.call_count == 0 + assert mock_vault.call_count == 0 diff --git a/tests/unit/runners/test_vault.py b/tests/unit/runners/test_vault.py index 8dc7734dbe8..e34445f4636 100644 --- a/tests/unit/runners/test_vault.py +++ b/tests/unit/runners/test_vault.py @@ -30,8 +30,13 @@ class VaultTest(TestCase, LoaderModuleMockMixin): "mixedcase": "UP-low-UP", } + self.pillar = { + "role": "test", + } + def tearDown(self): del self.grains + del self.pillar def test_get_policies_for_nonexisting_minions(self): minion_id = "salt_master" @@ -45,15 +50,15 @@ class VaultTest(TestCase, LoaderModuleMockMixin): "salt.utils.minions.get_minion_data", MagicMock(return_value=(None, None, None)), ): - for case, correct_output in cases.items(): - with patch.dict( - vault.__utils__, - { - "vault.expand_pattern_lists": Mock( - side_effect=lambda x, *args, **kwargs: [x] - ) - }, - ): + with patch.dict( + vault.__utils__, + { + "vault.expand_pattern_lists": Mock( + side_effect=lambda x, *args, **kwargs: [x] + ) + }, + ): + for case, correct_output in cases.items(): test_config = {"policies": [case]} output = vault._get_policies( minion_id, test_config @@ -77,21 +82,22 @@ class VaultTest(TestCase, LoaderModuleMockMixin): "Case-Should-Be-Lowered:{grains[mixedcase]}": [ "case-should-be-lowered:up-low-up" ], + "pillar-rendering:{pillar[role]}": ["pillar-rendering:test"], } with patch( "salt.utils.minions.get_minion_data", - MagicMock(return_value=(None, self.grains, None)), + MagicMock(return_value=(None, self.grains, self.pillar)), ): - for case, correct_output in cases.items(): - with patch.dict( - vault.__utils__, - { - "vault.expand_pattern_lists": Mock( - side_effect=lambda x, *args, **kwargs: [x] - ) - }, - ): + with patch.dict( + vault.__utils__, + { + "vault.expand_pattern_lists": Mock( + side_effect=lambda x, *args, **kwargs: [x] + ) + }, + ): + for case, correct_output in cases.items(): test_config = {"policies": [case]} output = vault._get_policies( "test-minion", test_config @@ -103,6 +109,39 @@ class VaultTest(TestCase, LoaderModuleMockMixin): log.debug("Difference:\n\t%s", diff) self.assertEqual(output, correct_output) + def test_get_policies_does_not_render_pillar_unnecessarily(self): + """ + The pillar data should only be refreshed in case items are accessed. + """ + get_pillar = Mock(name="g") + get_pillar.return_value.compile_pillar.return_value = self.pillar + + cases = [ + ("salt_minion_{minion}", 0), + ("salt_grain_{grains[id]}", 0), + ("unset_{foo}", 0), + ("salt_pillar_{pillar[role]}", 1), + ] + + with patch( + "salt.utils.minions.get_minion_data", + Mock(return_value=(None, self.grains, None)), + ), patch("salt.pillar.get_pillar", get_pillar): + with patch.dict( + vault.__utils__, + { + "vault.expand_pattern_lists": Mock( + side_effect=lambda x, *args, **kwargs: [x] + ) + }, + ): + for case, expected in cases: + test_config = {"policies": [case]} + vault._get_policies( + "test-minion", test_config, refresh_pillar=True + ) # pylint: disable=protected-access + assert get_pillar.call_count == expected + def test_get_token_create_url(self): """ Ensure _get_token_create_url parses config correctly @@ -170,6 +209,10 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin): "salt.runners.vault._get_token_create_url", MagicMock(return_value="http://fake_url"), ) + @patch( + "salt.runners.vault._get_policies_cached", + Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]), + ) def test_generate_token(self): """ Basic tests for test_generate_token: all exits @@ -231,7 +274,7 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin): self.assertTrue("error" in result) self.assertEqual(result["error"], "no reason") - with patch("salt.runners.vault._get_policies", MagicMock(return_value=[])): + with patch("salt.runners.vault._get_policies_cached", Mock(return_value=[])): result = vault.generate_token("test-minion", "signature") self.assertTrue(isinstance(result, dict)) self.assertTrue("error" in result) @@ -250,6 +293,10 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin): "salt.runners.vault._get_token_create_url", MagicMock(return_value="http://fake_url"), ) + @patch( + "salt.runners.vault._get_policies_cached", + Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]), + ) def test_generate_token_with_namespace(self): """ Basic tests for test_generate_token: all exits @@ -303,6 +350,10 @@ class VaultAppRoleAuthTest(TestCase, LoaderModuleMockMixin): "salt.runners.vault._get_token_create_url", MagicMock(return_value="http://fake_url"), ) + @patch( + "salt.runners.vault._get_policies_cached", + Mock(return_value=["saltstack/minion/test-minion", "saltstack/minions"]), + ) def test_generate_token(self): """ Basic test for test_generate_token with approle (two vault calls) diff --git a/tests/unit/sdb/test_vault.py b/tests/unit/sdb/test_vault.py index 7f033597daa..bba49dd975c 100644 --- a/tests/unit/sdb/test_vault.py +++ b/tests/unit/sdb/test_vault.py @@ -5,7 +5,7 @@ Test case for the vault SDB module import salt.sdb.vault as vault from tests.support.mixins import LoaderModuleMockMixin -from tests.support.mock import MagicMock, call, patch +from tests.support.mock import MagicMock, Mock, call, patch from tests.support.unit import TestCase @@ -201,3 +201,22 @@ class TestVaultSDB(LoaderModuleMockMixin, TestCase): assert mock_vault.call_args_list == [ call("GET", "v1/sdb://myvault/path/to/foo") ] + + def test_get_disabled_during_policy_pillar_rendering(self): + """ + Ensure that during pillar rendering for templated policies, + salt.sdb.vault.get function does not query vault to prevent + a cyclic dependency. + """ + mock_version = Mock() + mock_vault = Mock() + with patch.dict( + vault.__utils__, + {"vault.make_request": mock_vault, "vault.is_v2": mock_version}, + ): + with patch.dict( + vault.__opts__, {"_vault_runner_is_compiling_pillar_templates": True} + ): + self.assertIsNone(vault.get("sdb://myvault/path/to/foo/foo")) + assert mock_version.call_count == 0 + assert mock_vault.call_count == 0