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
This commit is contained in:
Liam Young 2018-03-29 14:07:26 +00:00
parent 2a5853c717
commit 5d16f8011d
8 changed files with 318 additions and 70 deletions

8
src/actions.yaml Normal file
View File

@ -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

61
src/actions/actions.py Executable file
View File

@ -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))

1
src/actions/authorize-charm Symbolic link
View File

@ -0,0 +1 @@
actions.py

120
src/lib/charm/vault.py Normal file
View File

@ -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)

View File

@ -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')

View File

@ -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')

View File

@ -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"

14
unit_tests/test_utils.py Normal file
View File

@ -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))