mirror of
https://github.com/saltstack/salt.git
synced 2025-04-16 09:40:20 +00:00
Initial port of 54094
This commit is contained in:
parent
8a2c331a47
commit
06b51f9d4e
6 changed files with 357 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
185
tests/unit/utils/test_vault.py
Normal file
185
tests/unit/utils/test_vault.py
Normal 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)
|
Loading…
Add table
Reference in a new issue