Features/vault enterprise (#58586)

* runner, utils and docs

* revert that

* changelog update

* revert to existing structure to unblock tests

* pulling in Teds fixes

* resolve uninitialized namespace

* Add namespace key to test inputs

* remove headers creation without Namespace

* Revert "remove headers creation without Namespace"

This reverts commit f72cb25ff5297cacc6121570fdb3a99967f34087.

* headers=ANY to account for namespace logic change

* Add unit tests

* lint update

* utils test fix

* runner, utils and docs

* revert to existing structure to unblock tests

* pulling in Teds fixes

* remove headers creation without Namespace

* Revert "remove headers creation without Namespace"

This reverts commit f72cb25ff5297cacc6121570fdb3a99967f34087.

* Add unit tests

* lint update

* runner, utils and docs

* revert that

* revert to existing structure to unblock tests

* pulling in Teds fixes

* Add namespace key to test inputs

* remove headers creation without Namespace

* Revert "remove headers creation without Namespace"

This reverts commit f72cb25ff5297cacc6121570fdb3a99967f34087.

* Add unit tests

* utils test fix

* revert doc chnages

* revert doc changes

* update docs

* make lint happy

* remove py2 dep

* layout fixes

* Update salt/runners/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/utils/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/utils/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update tests/unit/runners/test_vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update tests/unit/runners/test_vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update tests/unit/runners/test_vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Delete Issues_55775.txt.bak

* Update salt/utils/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/utils/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/utils/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/runners/vault.py

Co-authored-by: Pedro Algarvio <pedro@algarvio.me>

* Update salt/modules/vault.py

Co-authored-by: Megan Wilhite <megan.wilhite@gmail.com>

* Update vault.py

Linting

Co-authored-by: Patrick McConnell <pmcconnell@tucows.com>
Co-authored-by: vveliev <vveliev@tucows.com>
Co-authored-by: Pedro Algarvio <pedro@algarvio.me>
Co-authored-by: Megan Wilhite <megan.wilhite@gmail.com>
This commit is contained in:
Edmund Adderley 2021-04-29 11:11:21 -04:00 committed by GitHub
parent 72d64e30e5
commit 56911b683d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 134 additions and 23 deletions

1
changelog/58585.added Normal file
View file

@ -0,0 +1 @@
Added namespace headers to allow use of namespace from config to communicate with Vault Enterprise namespaces

View file

@ -25,6 +25,7 @@ Functions to interact with Hashicorp Vault.
url: https://vault.service.domain:8200
verify: /etc/ssl/certs/ca-certificates.crt
role_name: minion_role
namespace: vault_enterprice_namespace
auth:
method: approle
role_id: 11111111-2222-3333-4444-1111111111111
@ -49,6 +50,14 @@ Functions to interact with Hashicorp Vault.
.. versionadded:: 2018.3.0
namespaces
Optional Vault Namespace. Used with Vault enterprice
For detail please see:
https://www.vaultproject.io/docs/enterprise/namespaces
.. versionadded:: 3004
role_name
Role name for minion tokens created. If omitted, minion tokens will be
created without any role, thus being able to inherit any master token

View file

@ -52,6 +52,8 @@ def generate_token(
try:
config = __opts__.get("vault", {})
verify = config.get("verify", None)
# Vault Enterprise requires a namespace
namespace = config.get("namespace")
# Allow disabling of minion provided values via the master
allow_minion_override = config["auth"].get("allow_minion_override", False)
# This preserves the previous behavior of default TTL and 1 use
@ -66,17 +68,24 @@ def generate_token(
log.debug("Vault token expired. Recreating one")
# Requesting a short ttl token
url = "{}/v1/auth/approle/login".format(config["url"])
payload = {"role_id": config["auth"]["role_id"]}
if "secret_id" in config["auth"]:
payload["secret_id"] = config["auth"]["secret_id"]
response = requests.post(url, json=payload, verify=verify)
# Vault Enterprise call requires headers
headers = None
if namespace is not None:
headers = {"X-Vault-Namespace": namespace}
response = requests.post(
url, headers=headers, json=payload, verify=verify
)
if response.status_code != 200:
return {"error": response.reason}
config["auth"]["token"] = response.json()["auth"]["client_token"]
url = _get_token_create_url(config)
headers = {"X-Vault-Token": config["auth"]["token"]}
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
audit_data = {
"saltstack-jid": globals().get("__jid__", "<no jid set>"),
"saltstack-minion": minion_id,
@ -109,6 +118,7 @@ def generate_token(
"url": config["url"],
"verify": verify,
"token_backend": storage_type,
"namespace": namespace,
}
if uses >= 0:
ret["uses"] = uses
@ -267,10 +277,15 @@ def _selftoken_expired():
"""
try:
verify = __opts__["vault"].get("verify", None)
# Vault Enterprise requires a namespace
namespace = __opts__["vault"].get("namespace")
url = "{}/v1/auth/token/lookup-self".format(__opts__["vault"]["url"])
if "token" not in __opts__["vault"]["auth"]:
return True
headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
# Add Vault namespace to headers if Vault Enterprise enabled
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
response = requests.get(url, headers=headers, verify=verify)
if response.status_code != 200:
return True

View file

@ -103,6 +103,7 @@ def _get_token_and_url_from_master():
"url": result["url"],
"token": result["token"],
"verify": result.get("verify", None),
"namespace": result.get("namespace"),
"uses": result.get("uses", 1),
"lease_duration": result["lease_duration"],
"issued": result["issued"],
@ -117,6 +118,8 @@ def get_vault_connection():
def _use_local_config():
log.debug("Using Vault connection details from local config")
# Vault Enterprise requires a namespace
namespace = __opts__["vault"].get("namespace")
try:
if __opts__["vault"]["auth"]["method"] == "approle":
verify = __opts__["vault"].get("verify", None)
@ -127,7 +130,13 @@ def get_vault_connection():
payload = {"role_id": __opts__["vault"]["auth"]["role_id"]}
if "secret_id" in __opts__["vault"]["auth"]:
payload["secret_id"] = __opts__["vault"]["auth"]["secret_id"]
response = requests.post(url, json=payload, verify=verify)
if namespace is not None:
headers = {"X-Vault-Namespace": namespace}
response = requests.post(
url, headers=headers, json=payload, verify=verify
)
else:
response = requests.post(url, json=payload, verify=verify)
if response.status_code != 200:
errmsg = "An error occurred while getting a token from approle"
raise salt.exceptions.CommandExecutionError(errmsg)
@ -139,6 +148,8 @@ def get_vault_connection():
if _wrapped_token_valid():
url = "{}/v1/sys/wrapping/unwrap".format(__opts__["vault"]["url"])
headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
response = requests.post(url, headers=headers, verify=verify)
if response.status_code != 200:
errmsg = "An error occured while unwrapping vault token"
@ -148,6 +159,7 @@ def get_vault_connection():
]
return {
"url": __opts__["vault"]["url"],
"namespace": namespace,
"token": __opts__["vault"]["auth"]["token"],
"verify": __opts__["vault"].get("verify", None),
"issued": int(round(time.time())),
@ -287,6 +299,7 @@ def make_request(
resource,
token=None,
vault_url=None,
namespace=None,
get_token_url=False,
retry=False,
**args
@ -300,6 +313,7 @@ def make_request(
connection = get_cache()
token = connection["token"] if not token else token
vault_url = connection["url"] if not vault_url else vault_url
namespace = namespace or connection["namespace"]
if "verify" in args:
args["verify"] = args["verify"]
else:
@ -310,6 +324,8 @@ def make_request(
pass
url = "{}/{}".format(vault_url, resource)
headers = {"X-Vault-Token": str(token), "Content-Type": "application/json"}
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
response = requests.request(method, url, headers=headers, **args)
if not response.ok and response.json().get("errors", None) == ["permission denied"]:
log.info("Permission denied from vault")
@ -363,10 +379,14 @@ def _selftoken_expired():
"""
try:
verify = __opts__["vault"].get("verify", None)
# Vault Enterprise requires a namespace
namespace = __opts__["vault"].get("namespace")
url = "{}/v1/auth/token/lookup-self".format(__opts__["vault"]["url"])
if "token" not in __opts__["vault"]["auth"]:
return True
headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
response = requests.get(url, headers=headers, verify=verify)
if response.status_code != 200:
return True
@ -383,10 +403,14 @@ def _wrapped_token_valid():
"""
try:
verify = __opts__["vault"].get("verify", None)
# Vault Enterprise requires a namespace
namespace = __opts__["vault"].get("namespace")
url = "{}/v1/sys/wrapping/lookup".format(__opts__["vault"]["url"])
if "token" not in __opts__["vault"]["auth"]:
return False
headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
if namespace is not None:
headers["X-Vault-Namespace"] = namespace
response = requests.post(url, headers=headers, verify=verify)
if response.status_code != 200:
return False

View file

@ -1,19 +1,11 @@
# -*- coding: utf-8 -*-
"""
Unit tests for the Vault runner
"""
# Import Python Libs
from __future__ import absolute_import, print_function, unicode_literals
import logging
import salt.runners.vault as vault
# Import salt libs
from salt.ext import six
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import ANY, MagicMock, Mock, call, patch
from tests.support.unit import TestCase
@ -69,7 +61,7 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
# The mappings dict is assembled in _get_policies, so emulate here
mappings = {"minion": self.grains["id"], "grains": self.grains}
for case, correct_output in six.iteritems(cases):
for case, correct_output in cases.items():
output = vault._expand_pattern_lists(
case, **mappings
) # pylint: disable=protected-access
@ -85,14 +77,14 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
# For non-existing minions, or the master-minion, grains will be None
cases = {
"no-tokens-to-replace": ["no-tokens-to-replace"],
"single-dict:{minion}": ["single-dict:{0}".format(minion_id)],
"single-dict:{minion}": ["single-dict:{}".format(minion_id)],
"single-list:{grains[roles]}": [],
}
with patch(
"salt.utils.minions.get_minion_data",
MagicMock(return_value=(None, None, None)),
):
for case, correct_output in six.iteritems(cases):
for case, correct_output in cases.items():
test_config = {"policies": [case]}
output = vault._get_policies(
minion_id, test_config
@ -136,7 +128,7 @@ class VaultTest(TestCase, LoaderModuleMockMixin):
"salt.utils.minions.get_minion_data",
MagicMock(return_value=(None, self.grains, None)),
):
for case, correct_output in six.iteritems(cases):
for case, correct_output in cases.items():
test_config = {"policies": [case]}
output = vault._get_policies(
"test-minion", test_config
@ -290,6 +282,37 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
self.assertTrue("error" in result)
self.assertEqual(result["error"], "Test Exception Reason")
@patch("salt.runners.vault._validate_signature", MagicMock(return_value=None))
@patch(
"salt.runners.vault._get_token_create_url",
MagicMock(return_value="http://fake_url"),
)
def test_generate_token_with_namespace(self):
"""
Basic tests for test_generate_token: all exits
"""
mock = _mock_json_response(
{"auth": {"client_token": "test", "renewable": False, "lease_duration": 0}}
)
supplied_config = {"namespace": "test_namespace"}
with patch("requests.post", mock):
with patch.dict(vault.__opts__["vault"], supplied_config):
result = vault.generate_token("test-minion", "signature")
log.debug("generate_token result: %s", result)
self.assertIsInstance(result, dict)
self.assertNotIn("error", result)
self.assertIn("token", result)
self.assertEqual(result["token"], "test")
mock.assert_called_with(
"http://fake_url",
headers={
"X-Vault-Token": "test",
"X-Vault-Namespace": "test_namespace",
},
json=ANY,
verify=ANY,
)
class VaultAppRoleAuthTest(TestCase, LoaderModuleMockMixin):
"""
@ -332,7 +355,12 @@ class VaultAppRoleAuthTest(TestCase, LoaderModuleMockMixin):
self.assertTrue("token" in result)
self.assertEqual(result["token"], "test")
calls = [
call("http://127.0.0.1/v1/auth/approle/login", json=ANY, verify=ANY),
call(
"http://127.0.0.1/v1/auth/approle/login",
headers=ANY,
json=ANY,
verify=ANY,
),
call("http://fake_url", headers=ANY, json=ANY, verify=ANY),
]
mock.assert_has_calls(calls)

View file

@ -1,22 +1,15 @@
# -*- coding: utf-8 -*-
"""
Test case for the vault utils module
"""
# Import python libs
from __future__ import absolute_import, print_function, unicode_literals
import json
import logging
import os
from copy import copy
# Import Salt libs
import salt.utils.vault as vault
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import ANY, MagicMock, Mock, mock_open, patch
# Import Salt Testing libs
from tests.support.unit import TestCase
@ -57,6 +50,16 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 1,
"lease_duration": 100,
"issued": 3000,
}
cache_single_namespace = {
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": "test_namespace",
"uses": 1,
"lease_duration": 100,
"issued": 3000,
@ -65,6 +68,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 10,
"lease_duration": 100,
"issued": 3000,
@ -74,6 +78,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 1,
"lease_duration": 100,
"issued": 3000,
@ -83,6 +88,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 0,
"lease_duration": 100,
"issued": 3000,
@ -193,6 +199,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 9,
"lease_duration": 100,
"issued": 3000,
@ -327,6 +334,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 10,
"lease_duration": 100,
"issued": 3000,
@ -354,6 +362,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 0,
"lease_duration": 100,
"issued": 3000,
@ -362,6 +371,7 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"url": "http://127.0.0.1:8200",
"token": "test",
"verify": None,
"namespace": None,
"uses": 0,
"lease_duration": 100,
"issued": 3000,
@ -399,6 +409,30 @@ class TestVaultUtils(LoaderModuleMockMixin, TestCase):
function_return = vault.is_v2("secret/mything")
self.assertEqual(function_return, expected_return)
def test_request_with_namespace(self):
"""
Test request with namespace configured
"""
mock = self._mock_json_response(self.json_success)
expected_headers = {
"X-Vault-Token": "test",
"X-Vault-Namespace": "test_namespace",
"Content-Type": "application/json",
}
supplied_config = {"namespace": "test_namespace"}
supplied_context = {"vault_token": copy(self.cache_single_namespace)}
with patch.dict(vault.__context__, supplied_context):
with patch.dict(vault.__opts__["vault"], supplied_config):
with patch("requests.request", mock):
vault_return = vault.make_request("/secret/my/secret", "key")
mock.assert_called_with(
"/secret/my/secret",
"http://127.0.0.1:8200/key",
headers=expected_headers,
verify=ANY,
)
self.assertEqual(vault_return.json(), self.json_success)
def test_get_secret_path_metadata_no_cache(self):
"""
test with no cache file