Initial port of 54094

This commit is contained in:
Christian McHugh 2020-05-01 06:22:22 +01:00 committed by Daniel Wozniak
parent 8a2c331a47
commit 06b51f9d4e
6 changed files with 357 additions and 16 deletions

View file

@ -147,3 +147,20 @@ A new renderer for toml files has been added.
txt = "hello"
[["some id"."test.nop"]]
"somekey" = "somevalue"
Execution Module updates
========================
Vault Module
------------
The :py:func:`vault module <salt.modules.vault>` has been updated with the ability
to cache generated tokens. By specifying ``ttl`` or ``uses`` the token generated on
behalf of the minion will be allowed to persist and function for the defined time period
or number of uses.
.. code-block:: yaml
vault:
auth:
uses: 25

View file

@ -90,6 +90,13 @@ Functions to interact with Hashicorp Vault.
export VAULT_TOKEN=11111111-1111-1111-1111-1111111111111
Configuration keys ``uses`` or ``ttl`` may also be specified under auth
to configure the tokens generated on behalf of minions to be reused for the
defined time length or number of uses. These settings may also be configured
on the minion when
.. versionchanged:: Sodium
policies
Policies that are assigned to minions when requesting a token. These can
either be static, eg saltstack/minions, or templated with grain values,

View file

@ -14,6 +14,7 @@ import base64
import json
import logging
import string
import time
import requests
@ -27,7 +28,9 @@ from salt.ext import six
log = logging.getLogger(__name__)
def generate_token(minion_id, signature, impersonated_by_master=False):
def generate_token(
minion_id, signature, impersonated_by_master=False, ttl=None, uses=None
):
"""
Generate a Vault token for minion minion_id
@ -41,6 +44,12 @@ def generate_token(minion_id, signature, impersonated_by_master=False):
impersonated_by_master
If the master needs to create a token on behalf of the minion, this is
True. This happens when the master generates minion pillars.
ttl
Ticket time to live in seconds, 1m minutes, or 2h hrs
uses
Number of times a token can be used
"""
log.debug(
"Token generation request for %s (impersonated by master: %s)",
@ -48,10 +57,23 @@ def generate_token(minion_id, signature, impersonated_by_master=False):
impersonated_by_master,
)
_validate_signature(minion_id, signature, impersonated_by_master)
try:
config = __opts__["vault"]
config = __opts__.get("vault", {})
verify = config.get("verify", None)
# Allow disabling of minion provided values via the master
allow_minion_override = config.get("minion_auth", {}).get(
"allow_minion_override", True
)
# This preserves the previous behavior of default TTL and 1 use
if not allow_minion_override or uses is None:
uses = config.get("minion_auth", {}).get("uses", 1)
if not allow_minion_override or ttl is None:
ttl = config.get("minion_auth", {}).get("ttl", None)
try:
# Ensure uses is valid
assert uses >= 0
except AssertionError:
uses = 1
if config["auth"]["method"] == "approle":
if _selftoken_expired():
@ -76,25 +98,36 @@ def generate_token(minion_id, signature, impersonated_by_master=False):
}
payload = {
"policies": _get_policies(minion_id, config),
"num_uses": 1,
"num_uses": uses,
"meta": audit_data,
}
if ttl is not None:
payload["ttl"] = str(ttl)
if payload["policies"] == []:
return {"error": "No policies matched minion"}
log.trace("Sending token creation request to Vault")
log.error(f"url: {url} headers: {headers} payload: {payload} verify: {verify}")
response = requests.post(url, headers=headers, json=payload, verify=verify)
if response.status_code != 200:
return {"error": response.reason}
auth_data = response.json()["auth"]
return {
ret = {
"token": auth_data["client_token"],
"lease_duration": auth_data["lease_duration"],
"renewable": auth_data["renewable"],
"issued": int(round(time.time())),
"url": config["url"],
"verify": verify,
}
if uses > 0:
ret["uses"] = uses
return ret
except Exception as e: # pylint: disable=broad-except
return {"error": six.text_type(e)}

View file

@ -12,6 +12,7 @@ from __future__ import absolute_import, print_function, unicode_literals
import base64
import logging
import time
import requests
import salt.crypt
@ -44,6 +45,9 @@ def _get_token_and_url_from_master():
"""
minion_id = __grains__["id"]
pki_dir = __opts__["pki_dir"]
# Allow minion override salt-master settings/defaults
uses = __opts__.get("vault", {}).get("auth", {}).get("uses", None)
ttl = __opts__.get("vault", {}).get("auth", {}).get("ttl", None)
# When rendering pillars, the module executes on the master, but the token
# should be issued for the minion, so that the correct policies are applied
@ -52,7 +56,7 @@ def _get_token_and_url_from_master():
log.debug("Running on minion, signing token request with key %s", private_key)
signature = base64.b64encode(salt.crypt.sign_message(private_key, minion_id))
result = __salt__["publish.runner"](
"vault.generate_token", arg=[minion_id, signature]
"vault.generate_token", arg=[minion_id, signature, False, ttl, uses]
)
else:
private_key = "{0}/master.pem".format(pki_dir)
@ -67,6 +71,8 @@ def _get_token_and_url_from_master():
minion_id=minion_id,
signature=signature,
impersonated_by_master=True,
ttl=ttl,
uses=uses,
)
if not result:
@ -90,6 +96,7 @@ def _get_token_and_url_from_master():
"url": result["url"],
"token": result["token"],
"verify": result.get("verify", None),
"uses": uses,
}
@ -134,6 +141,8 @@ def get_vault_connection():
"url": __opts__["vault"]["url"],
"token": __opts__["vault"]["auth"]["token"],
"verify": __opts__["vault"].get("verify", None),
"issued": int(round(time.time())),
"ttl": 3600,
}
except KeyError as err:
errmsg = 'Minion has "vault" config section, but could not find key "{0}" within'.format(
@ -149,9 +158,9 @@ def get_vault_connection():
return _use_local_config()
elif any(
(
__opts__["local"],
__opts__["file_client"] == "local",
__opts__["master_type"] == "disable",
__opts__.get("local", None),
__opts__.get("file_client", None) == "local",
__opts__.get("master_type", None) == "disable",
)
):
return _use_local_config()
@ -166,16 +175,65 @@ def make_request(
"""
Make a request to Vault
"""
if not token or not vault_url:
connection = get_vault_connection()
token, vault_url = connection["token"], connection["url"]
if "verify" not in args:
args["verify"] = connection["verify"]
def _get_new_connection():
log.debug("Getting new token")
__context__[cache_key] = get_vault_connection()
cache_key = "salt_vault_token"
if cache_key in __context__:
log.debug("Found cached vault token")
# We drop 10 seconds just be safe
ttl10 = (
__context__[cache_key]["issued"]
+ __context__[cache_key]["lease_duration"]
- 10
)
cur_time = int(round(time.time()))
if __context__[cache_key].get("uses", 1) <= 0:
log.debug(
"Cached token has no more uses left {}: DELETING".format(
__context__[cache_key]["uses"]
)
)
del __context__[cache_key]
_get_new_connection()
else:
log.debug(
"Token has {} uses left".format(
__context__[cache_key].get("uses", "infinity")
)
)
if __context__.get(cache_key, False) and ttl10 < cur_time:
log.debug(
"Cached token has expired {} < {}: DELETING".format(ttl10, cur_time)
)
del __context__[cache_key]
_get_new_connection()
elif __context__.get(cache_key, False):
log.debug("Token has not expired {} > {}".format(ttl10, cur_time))
else:
_get_new_connection()
token = __context__[cache_key]["token"] if not token else token
vault_url = __context__[cache_key]["url"] if not vault_url else vault_url
args["verify"] = (
__opts__["vault"].get("verify", None)
if "verify" not in args
else args["verify"]
)
url = "{0}/{1}".format(vault_url, resource)
headers = {"X-Vault-Token": token, "Content-Type": "application/json"}
response = requests.request(method, url, headers=headers, **args)
if __context__[cache_key].get("uses", None):
log.debug("Decrementing Vault uses on limited token")
__context__[cache_key]["uses"] -= 1
if get_token_url:
return response, token, vault_url
else:

View file

@ -215,7 +215,9 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
"""
Basic tests for test_generate_token: all exits
"""
mock = _mock_json_response({"auth": {"client_token": "test"}})
mock = _mock_json_response(
{"auth": {"client_token": "test", "renewable": False, "lease_duration": 0}}
)
with patch("requests.post", mock):
result = vault.generate_token("test-minion", "signature")
log.debug("generate_token result: %s", result)
@ -227,6 +229,43 @@ class VaultTokenAuthTest(TestCase, LoaderModuleMockMixin):
"http://fake_url", headers=ANY, json=ANY, verify=ANY
)
# Test uses
num_uses = 6
result = vault.generate_token("test-minion", "signature", uses=num_uses)
print(f"generate result: {result}")
self.assertTrue("uses" in result)
self.assertTrue(result["uses"] == num_uses)
json_request = {
"policies": ["saltstack/minion/test-minion", "saltstack/minions"],
"num_uses": num_uses,
"meta": {
"saltstack-jid": "<no jid set>",
"saltstack-minion": "test-minion",
"saltstack-user": "<no user set>",
},
}
mock.assert_called_with(
"http://fake_url", headers=ANY, json=json_request, verify=ANY
)
# Test ttl
expected_ttl = "6h"
result = vault.generate_token("test-minion", "signature", ttl=expected_ttl)
self.assertTrue(result["uses"] == 1)
json_request = {
"policies": ["saltstack/minion/test-minion", "saltstack/minions"],
"num_uses": 1,
"ttl": expected_ttl,
"meta": {
"saltstack-jid": "<no jid set>",
"saltstack-minion": "test-minion",
"saltstack-user": "<no user set>",
},
}
mock.assert_called_with(
"http://fake_url", headers=ANY, json=json_request, verify=ANY
)
mock = _mock_json_response({}, status_code=403, reason="no reason")
with patch("requests.post", mock):
result = vault.generate_token("test-minion", "signature")
@ -279,7 +318,9 @@ class VaultAppRoleAuthTest(TestCase, LoaderModuleMockMixin):
"""
Basic test for test_generate_token with approle (two vault calls)
"""
mock = _mock_json_response({"auth": {"client_token": "test"}})
mock = _mock_json_response(
{"auth": {"client_token": "test", "renewable": False, "lease_duration": 0}}
)
with patch("requests.post", mock):
result = vault.generate_token("test-minion", "signature")
log.debug("generate_token result: %s", result)

View file

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""
Test case for the vault utils module
"""
# Import python libs
from __future__ import absolute_import, print_function, unicode_literals
# Import Salt libs
import salt.utils.vault as vault
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import Mock, patch
# Import Salt Testing libs
from tests.support.unit import TestCase
class RequestMock(Mock):
"""
Request Mock
"""
def get(self, *args, **kwargs):
return {}
class TestVaultUtils(LoaderModuleMockMixin, TestCase):
"""
Test case for the vault utils module
"""
def setup_loader_modules(self):
return {
vault: {
"__opts__": {
"vault": {
"url": "http://127.0.0.1",
"auth": {
"token": "test",
"method": "token",
"uses": 15,
"ttl": 500,
},
},
"file_client": "local",
},
"__grains__": {"id": "test-minion"},
"requests": RequestMock(),
"__context__": {},
}
}
def test_make_request_no_cache(self):
"""
Given no cache, function should request token and populate cache
"""
expected_context = {
"salt_vault_token": {
"url": "http://127.0.0.1",
"token": "test",
"verify": None,
"issued": 1234,
"ttl": 3600,
}
}
with patch("time.time", return_value=1234):
vault_return = vault.make_request("/secret/my/secret", "key")
self.assertEqual(vault.__context__, expected_context)
def test_make_request_ttl_cache(self):
"""
Given a valid issued date (greater than time.time result), cache should be re-used
"""
local_context = {
"salt_vault_token": {
"issued": 3000,
"lease_duration": 20,
"token": "atest",
"url": "http://127.1.1.1",
}
}
expected_context = {
"salt_vault_token": {
"token": "atest",
"issued": 3000,
"url": "http://127.1.1.1",
"lease_duration": 20,
}
}
with patch("time.time", return_value=1234):
with patch.dict(vault.__context__, local_context):
vault_return = vault.make_request("/secret/my/secret", "key")
self.assertDictEqual(vault.__context__, expected_context)
def test_make_request_expired_ttl_cache(self):
"""
Given an expired issued date, function should notice and regenerate token and populate cache
"""
local_context = {
"salt_vault_token": {
"issued": 1000,
"lease_duration": 20,
"token": "atest",
"url": "http://127.1.1.1",
}
}
expected_context = {
"salt_vault_token": {
"token": "atest",
"issued": 3000,
"url": "http://127.1.1.1",
"lease_duration": 20,
}
}
with patch("time.time", return_value=1234):
with patch.dict(vault.__context__, local_context):
with patch.object(
vault,
"get_vault_connection",
return_value=expected_context["salt_vault_token"],
):
vault_return = vault.make_request("/secret/my/secret", "key")
self.assertDictEqual(vault.__context__, expected_context)
def test_make_request_expired_uses_cache(self):
"""
Given 0 cached uses left, function should notice and regenerate token and populate cache
"""
local_context = {
"salt_vault_token": {
"uses": 0,
"issued": 1000,
"lease_duration": 20,
"token": "atest",
"url": "http://127.1.1.1",
}
}
expected_context = {
"salt_vault_token": {
"token": "atest",
"issued": 3000,
"url": "http://127.1.1.1",
"lease_duration": 20,
}
}
with patch.dict(vault.__context__, local_context):
with patch.object(
vault,
"get_vault_connection",
return_value=expected_context["salt_vault_token"],
):
vault_return = vault.make_request("/secret/my/secret", "key")
self.assertDictEqual(vault.__context__, expected_context)
def test_make_request_remaining_uses_cache(self):
"""
Given remaining uses, function should reuse cache
"""
local_context = {
"salt_vault_token": {
"uses": 3,
"issued": 3000,
"lease_duration": 20,
"token": "atest",
"url": "http://127.1.1.1",
}
}
expected_context = {
"salt_vault_token": {
"uses": 2,
"token": "atest",
"issued": 3000,
"url": "http://127.1.1.1",
"lease_duration": 20,
}
}
with patch("time.time", return_value=1234):
with patch.dict(vault.__context__, local_context):
with patch.object(
vault,
"get_vault_connection",
return_value=expected_context["salt_vault_token"],
):
vault_return = vault.make_request("/secret/my/secret", "key")
self.assertDictEqual(vault.__context__, expected_context)