From 5d16f8011d48b40981ac74474359279a9aaed42b Mon Sep 17 00:00:00 2001 From: Liam Young Date: Thu, 29 Mar 2018 14:07:26 +0000 Subject: [PATCH] Add action to allow charm to make calls to vault Add an action which can be run after vault has been manually initialised and unsealed. The action creates a role that allows localhost calls. Change-Id: I042b49248db32e6e9a7814c74d8c25939918d0ce --- src/actions.yaml | 8 ++ src/actions/actions.py | 61 +++++++++ src/actions/authorize-charm | 1 + src/lib/charm/vault.py | 120 ++++++++++++++++++ src/reactive/{vault.py => vault_handlers.py} | 34 +---- unit_tests/test_lib_charm_vault.py | 99 +++++++++++++++ ...ult.py => test_reactive_vault_handlers.py} | 51 ++------ unit_tests/test_utils.py | 14 ++ 8 files changed, 318 insertions(+), 70 deletions(-) create mode 100644 src/actions.yaml create mode 100755 src/actions/actions.py create mode 120000 src/actions/authorize-charm create mode 100644 src/lib/charm/vault.py rename src/reactive/{vault.py => vault_handlers.py} (95%) create mode 100644 unit_tests/test_lib_charm_vault.py rename unit_tests/{test_vault.py => test_reactive_vault_handlers.py} (91%) create mode 100644 unit_tests/test_utils.py diff --git a/src/actions.yaml b/src/actions.yaml new file mode 100644 index 0000000..7b3ff6a --- /dev/null +++ b/src/actions.yaml @@ -0,0 +1,8 @@ +authorize-charm: + description: Authorize the vault charm to interact with vault + properties: + token: + type: string + description: Token to use to authorize charm + required: + - token diff --git a/src/actions/actions.py b/src/actions/actions.py new file mode 100755 index 0000000..4b97f68 --- /dev/null +++ b/src/actions/actions.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Copyright 2018 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +# Load modules from $CHARM_DIR/lib +sys.path.append('lib') + +from charms.layer import basic +basic.bootstrap_charm_deps() +basic.init_config_states() + +import charmhelpers.core.hookenv as hookenv + +import charm.vault as vault + + +def authorize_charm_action(*args): + """Create a role allowing the charm to perform certain vault actions. + """ + if not hookenv.is_leader(): + hookenv.action_fail('Please run action on lead unit') + action_config = hookenv.action_get() + role_id = vault.setup_charm_vault_access(action_config['token']) + hookenv.leader_set({vault.CHARM_ACCESS_ROLE_ID: role_id}) + +# Actions to function mapping, to allow for illegal python action names that +# can map to a python function. +ACTIONS = { + "authorize-charm": authorize_charm_action, +} + + +def main(args): + action_name = os.path.basename(args[0]) + try: + action = ACTIONS[action_name] + except KeyError: + return "Action %s undefined" % action_name + else: + try: + action(args) + except Exception as e: + hookenv.action_fail(str(e)) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/actions/authorize-charm b/src/actions/authorize-charm new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/authorize-charm @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/lib/charm/vault.py b/src/lib/charm/vault.py new file mode 100644 index 0000000..e7d7d43 --- /dev/null +++ b/src/lib/charm/vault.py @@ -0,0 +1,120 @@ +import functools +import hvac + +import charmhelpers.core.hookenv as hookenv +import charms.reactive + +CHARM_ACCESS_ROLE = 'local-charm-access' +CHARM_ACCESS_ROLE_ID = 'local-charm-access-id' +CHARM_POLICY_NAME = 'local-charm-policy' +CHARM_POLICY = """ +# Allow managment of policies starting with charm- prefix +path "sys/policy/charm-*" { + capabilities = ["create", "read", "update", "delete"] +} + +# Allow discovery of all policies +path "sys/policy/" { + capabilities = ["list"] +} + +# Allow management of approle's with charm- prefix +path "auth/approle/role/charm-*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Allow discovery of approles +path "auth/approle/role" { + capabilities = ["read"] +} +path "auth/approle/role/" { + capabilities = ["list"] +} + +# Allow charm- prefixes secrets backends to be mounted and managed +path "sys/mounts/charm-*" { + capabilities = ["create", "read", "update", "delete", "sudo"] +} + +# Allow discovery of secrets backends +path "sys/mounts" { + capabilities = ["read"] +} +path "sys/mounts/" { + capabilities = ["list"] +}""" + + +def binding_address(binding): + try: + return hookenv.network_get_primary_address(binding) + except NotImplementedError: + return hookenv.unit_private_ip() + + +def get_vault_url(binding, port): + protocol = 'http' + ip = binding_address(binding) + if charms.reactive.is_state('vault.ssl.available'): + protocol = 'https' + return '{}://{}:{}'.format(protocol, ip, port) + + +get_api_url = functools.partial(get_vault_url, + binding='access', port=8200) +get_cluster_url = functools.partial(get_vault_url, + binding='cluster', port=8201) + + +def enable_approle_auth(client): + """Enable the approle auth method within vault + + :param client: Vault client + :type client: hvac.Client""" + if 'approle/' not in client.list_auth_backends(): + client.enable_auth_backend('approle') + + +def create_local_charm_access_role(client, policies): + """Create a role within vault associating the supplied policies + + :param client: Vault client + :type client: hvac.Client + :param policies: List of policy names + :type policies: [str, str, ...] + :returns: Id of created role + :rtype: str""" + client.create_role( + CHARM_ACCESS_ROLE, + token_ttl='60s', + token_max_ttl='60s', + policies=policies, + bind_secret_id='false', + bound_cidr_list='127.0.0.1/32') + return client.get_role_id(CHARM_ACCESS_ROLE) + + +def setup_charm_vault_access(token): + """Create policies and role. Grant role to charm. + + :param token: Token to use to authenticate with vault + :type token: str + :returns: Id of created role + :rtype: str""" + vault_url = get_api_url() + client = hvac.Client( + url=vault_url, + token=token) + enable_approle_auth(client) + policies = [CHARM_POLICY_NAME] + client.set_policy(CHARM_POLICY_NAME, CHARM_POLICY) + return create_local_charm_access_role(client, policies=policies) + + +def get_local_charm_access_role_id(): + """Retrieve the id of the role for local charm access + + :returns: Id of local charm access role + :rtype: str + """ + return hookenv.leader_get(CHARM_ACCESS_ROLE_ID) diff --git a/src/reactive/vault.py b/src/reactive/vault_handlers.py similarity index 95% rename from src/reactive/vault.py rename to src/reactive/vault_handlers.py index a90ff8a..86c4a9c 100644 --- a/src/reactive/vault.py +++ b/src/reactive/vault_handlers.py @@ -1,5 +1,4 @@ import base64 -import functools import hvac import psycopg2 import requests @@ -19,13 +18,13 @@ from charmhelpers.core.hookenv import ( ERROR, config, log, + network_get_primary_address, open_port, status_set, unit_private_ip, application_version_set, atexit, local_unit, - network_get_primary_address, ) from charmhelpers.core.host import ( @@ -59,6 +58,7 @@ from charms.reactive.flags import ( ) from charms.layer import snap +import lib.charm.vault as vault # See https://www.vaultproject.io/docs/configuration/storage/postgresql.html @@ -87,14 +87,15 @@ REQUIRED_INTERFACES = [ def get_client(): - return hvac.Client(url=get_api_url()) + return hvac.Client(url=vault.get_api_url()) @tenacity.retry(wait=tenacity.wait_exponential(multiplier=1, max=10), stop=tenacity.stop_after_attempt(10), reraise=True) def get_vault_health(): - response = requests.get(VAULT_HEALTH_URL.format(vault_addr=get_api_url())) + response = requests.get( + VAULT_HEALTH_URL.format(vault_addr=vault.get_api_url())) return response.json() @@ -182,8 +183,8 @@ def configure_vault(context): key=context['etcd_tls_key_file'], cert=context['etcd_tls_cert_file'], ca=context['etcd_tls_ca_file']) - context['api_addr'] = get_api_url() - context['cluster_addr'] = get_cluster_url() + context['api_addr'] = vault.get_api_url() + context['cluster_addr'] = vault.get_cluster_url() log("Etcd detected, setting api_addr to {}".format( context['api_addr'])) else: @@ -209,27 +210,6 @@ def configure_vault(context): open_port(8200) -def binding_address(binding): - try: - return network_get_primary_address(binding) - except NotImplementedError: - return unit_private_ip() - - -def get_vault_url(binding, port): - protocol = 'http' - ip = binding_address(binding) - if is_state('vault.ssl.available'): - protocol = 'https' - return '{}://{}:{}'.format(protocol, ip, port) - - -get_api_url = functools.partial(get_vault_url, - binding='access', port=8200) -get_cluster_url = functools.partial(get_vault_url, - binding='cluster', port=8201) - - @when('snap.installed.vault') @when_not('configured') @when('db.master.available') diff --git a/unit_tests/test_lib_charm_vault.py b/unit_tests/test_lib_charm_vault.py new file mode 100644 index 0000000..eaf3e97 --- /dev/null +++ b/unit_tests/test_lib_charm_vault.py @@ -0,0 +1,99 @@ +import mock +from unittest.mock import patch + +import lib.charm.vault as vault +import unit_tests.test_utils + + +class TestLibCharmVault(unit_tests.test_utils.CharmTestCase): + + def setUp(self): + super(TestLibCharmVault, self).setUp() + self.obj = vault + self.patches = [] + self.patch_all() + + def test_enable_approle_auth(self): + client_mock = mock.MagicMock() + client_mock.list_auth_backends.return_value = [] + vault.enable_approle_auth(client_mock) + client_mock.enable_auth_backend.assert_called_once_with('approle') + + def test_enable_approle_auth_mounted(self): + client_mock = mock.MagicMock() + client_mock.list_auth_backends.return_value = ['approle/'] + vault.enable_approle_auth(client_mock) + self.assertFalse(client_mock.enable_auth_backend.called) + + def test_create_local_charm_access_role(self): + client_mock = mock.MagicMock() + client_mock.get_role_id.return_value = '123' + policies = ['policy1', 'pilicy2'] + role_id = vault.create_local_charm_access_role(client_mock, policies) + self.assertEqual(role_id, '123') + client_mock.create_role.assert_called_once_with( + 'local-charm-access', + bind_secret_id='false', + bound_cidr_list='127.0.0.1/32', + policies=['policy1', 'pilicy2'], + token_max_ttl='60s', + token_ttl='60s') + + @patch.object(vault.hvac, 'Client') + @patch.object(vault, 'get_api_url') + @patch.object(vault, 'enable_approle_auth') + @patch.object(vault, 'create_local_charm_access_role') + def test_setup_charm_vault_access(self, + mock_create_local_charm_access_role, + mock_enable_approle_auth, + mock_get_api_url, + mock_Client): + client_mock = mock.MagicMock() + mock_Client.return_value = client_mock + vault.setup_charm_vault_access('mytoken') + mock_enable_approle_auth.assert_called_once_with(client_mock) + policy_calls = [ + mock.call('local-charm-policy', mock.ANY)] + client_mock.set_policy.assert_has_calls(policy_calls) + mock_create_local_charm_access_role.assert_called_once_with( + client_mock, + policies=['local-charm-policy']) + + @patch.object(vault.hookenv, 'leader_get') + def test_get_local_charm_access_role_id(self, mock_leader_get): + leader_db = {'local-charm-access-id': '12'} + mock_leader_get.side_effect = lambda x: leader_db[x] + self.assertEqual(vault.get_local_charm_access_role_id(), '12') + + @patch.object(vault.hookenv, 'network_get_primary_address') + @patch.object(vault.charms.reactive, 'is_state') + def test_get_api_url_ssl(self, is_state, network_get_primary_address): + is_state.return_value = True + network_get_primary_address.return_value = '1.2.3.4' + self.assertEqual(vault.get_api_url(), 'https://1.2.3.4:8200') + network_get_primary_address.assert_called_with('access') + + @patch.object(vault.hookenv, 'network_get_primary_address') + @patch.object(vault.charms.reactive, 'is_state') + def test_get_api_url_nossl(self, is_state, network_get_primary_address): + is_state.return_value = False + network_get_primary_address.return_value = '1.2.3.4' + self.assertEqual(vault.get_api_url(), 'http://1.2.3.4:8200') + network_get_primary_address.assert_called_with('access') + + @patch.object(vault.hookenv, 'network_get_primary_address') + @patch.object(vault.charms.reactive, 'is_state') + def test_get_cluster_url_ssl(self, is_state, network_get_primary_address): + is_state.return_value = True + network_get_primary_address.return_value = '1.2.3.4' + self.assertEqual(vault.get_cluster_url(), 'https://1.2.3.4:8201') + network_get_primary_address.assert_called_with('cluster') + + @patch.object(vault.hookenv, 'network_get_primary_address') + @patch.object(vault.charms.reactive, 'is_state') + def test_get_cluster_url_nossl(self, is_state, + network_get_primary_address): + is_state.return_value = False + network_get_primary_address.return_value = '1.2.3.4' + self.assertEqual(vault.get_cluster_url(), 'http://1.2.3.4:8201') + network_get_primary_address.assert_called_with('cluster') diff --git a/unit_tests/test_vault.py b/unit_tests/test_reactive_vault_handlers.py similarity index 91% rename from unit_tests/test_vault.py rename to unit_tests/test_reactive_vault_handlers.py index 7b19357..c87b44a 100644 --- a/unit_tests/test_vault.py +++ b/unit_tests/test_reactive_vault_handlers.py @@ -1,5 +1,4 @@ import mock -import unittest from unittest.mock import patch import charms.reactive @@ -11,10 +10,11 @@ charms.reactive.hook = dec_mock charms.reactive.when = dec_mock charms.reactive.when_not = dec_mock -import reactive.vault as handlers # noqa: E402 +import reactive.vault_handlers as handlers # noqa: E402 +import unit_tests.test_utils -class TestHandlers(unittest.TestCase): +class TestHandlers(unit_tests.test_utils.CharmTestCase): _health_response = { "initialized": True, @@ -48,6 +48,7 @@ class TestHandlers(unittest.TestCase): def setUp(self): super(TestHandlers, self).setUp() + self.obj = handlers self.patches = [ 'config', 'endpoint_from_flag', @@ -62,10 +63,8 @@ class TestHandlers(unittest.TestCase): 'status_set', 'remove_state', 'render', - 'unit_private_ip', 'application_version_set', 'local_unit', - 'network_get_primary_address', 'snap', 'is_flag_set', 'set_flag', @@ -73,16 +72,6 @@ class TestHandlers(unittest.TestCase): ] self.patch_all() - def _patch(self, method): - _m = patch.object(handlers, method) - mock = _m.start() - self.addCleanup(_m.stop) - return mock - - def patch_all(self): - for method in self.patches: - setattr(self, method, self._patch(method)) - def test_ssl_available(self): self.assertFalse(handlers.ssl_available({ 'ssl-cert': '', @@ -225,9 +214,9 @@ class TestHandlers(unittest.TestCase): ]) @patch.object(handlers, 'save_etcd_client_credentials') - @patch.object(handlers, 'get_cluster_url') + @patch.object(handlers.vault, 'get_cluster_url') @patch.object(handlers, 'can_restart') - @patch.object(handlers, 'get_api_url') + @patch.object(handlers.vault, 'get_api_url') def test_configure_vault_etcd(self, get_api_url, can_restart, get_cluster_url, save_etcd_client_credentials): @@ -272,7 +261,7 @@ class TestHandlers(unittest.TestCase): self.is_flag_set.assert_called_with('etcd.tls.available') @patch.object(handlers.hvac, 'Client') - @patch.object(handlers, 'get_api_url') + @patch.object(handlers.vault, 'get_api_url') def test_get_client(self, get_api_url, hvac_Client): get_api_url.return_value = 'http://this-unit' handlers.get_client() @@ -305,31 +294,7 @@ class TestHandlers(unittest.TestCase): get_client.return_value = hvac_mock self.assertFalse(handlers.can_restart()) - def test_get_api_url_ssl(self): - self.is_state.return_value = True - self.network_get_primary_address.return_value = '1.2.3.4' - self.assertEqual(handlers.get_api_url(), 'https://1.2.3.4:8200') - self.network_get_primary_address.assert_called_with('access') - - def test_get_api_url_nossl(self): - self.is_state.return_value = False - self.network_get_primary_address.return_value = '1.2.3.4' - self.assertEqual(handlers.get_api_url(), 'http://1.2.3.4:8200') - self.network_get_primary_address.assert_called_with('access') - - def test_get_cluster_url_ssl(self): - self.is_state.return_value = True - self.network_get_primary_address.return_value = '1.2.3.4' - self.assertEqual(handlers.get_cluster_url(), 'https://1.2.3.4:8201') - self.network_get_primary_address.assert_called_with('cluster') - - def test_get_cluster_url_nossl(self): - self.is_state.return_value = False - self.network_get_primary_address.return_value = '1.2.3.4' - self.assertEqual(handlers.get_cluster_url(), 'http://1.2.3.4:8201') - self.network_get_primary_address.assert_called_with('cluster') - - @patch.object(handlers, 'get_api_url') + @patch.object(handlers.vault, 'get_api_url') @patch.object(handlers, 'requests') def test_get_vault_health(self, requests, get_api_url): get_api_url.return_value = "https://vault.demo.com:8200" diff --git a/unit_tests/test_utils.py b/unit_tests/test_utils.py new file mode 100644 index 0000000..15fe317 --- /dev/null +++ b/unit_tests/test_utils.py @@ -0,0 +1,14 @@ +import unittest + + +class CharmTestCase(unittest.TestCase): + + def _patch(self, method): + _m = unittest.mock.patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self._patch(method))