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:
parent
2a5853c717
commit
5d16f8011d
|
@ -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
|
|
@ -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))
|
|
@ -0,0 +1 @@
|
|||
actions.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)
|
|
@ -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')
|
|
@ -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')
|
|
@ -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"
|
|
@ -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))
|
Loading…
Reference in New Issue