diff --git a/castellan/key_manager/__init__.py b/castellan/key_manager/__init__.py index 362ba3da..1e4f4145 100644 --- a/castellan/key_manager/__init__.py +++ b/castellan/key_manager/__init__.py @@ -25,8 +25,9 @@ key_manager_opts = [ default='barbican', deprecated_name='api_class', deprecated_group='key_manager', - help='Specify the key manager implementation. Default is ' - '"barbican".Will support the values earlier set using ' + help='Specify the key manager implementation. Options are ' + '"barbican" and "vault". Default is "barbican". Will ' + 'support the values earlier set using ' '[key_manager]/api_class for some time.'), ] diff --git a/castellan/key_manager/vault_key_manager.py b/castellan/key_manager/vault_key_manager.py new file mode 100644 index 00000000..6575ade3 --- /dev/null +++ b/castellan/key_manager/vault_key_manager.py @@ -0,0 +1,297 @@ +# 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. + +""" +Key manager implementation for Vault +""" + +import binascii +import os +import time +import uuid + +from keystoneauth1 import loading +from oslo_config import cfg +from oslo_log import log as logging +import requests +import six + +from castellan.common import exception +from castellan.common.objects import opaque_data as op_data +from castellan.common.objects import passphrase +from castellan.common.objects import private_key as pri_key +from castellan.common.objects import public_key as pub_key +from castellan.common.objects import symmetric_key as sym_key +from castellan.common.objects import x_509 +from castellan.i18n import _ +from castellan.key_manager import key_manager + +DEFAULT_VAULT_URL = "http://127.0.0.1:8200" + +vault_opts = [ + cfg.StrOpt('root_token_id', + help='root token for vault'), + cfg.StrOpt('vault_url', + default=DEFAULT_VAULT_URL, + help='Use this endpoint to connect to Vault, for example: ' + '"%s"' % DEFAULT_VAULT_URL), + cfg.StrOpt('ssl_ca_crt_file', + help='Absolute path to ca cert file'), + cfg.BoolOpt('use_ssl', + default=False, + help=_('SSL Enabled/Disabled')), +] + +VAULT_OPT_GROUP = 'vault' + +_EXCEPTIONS_BY_CODE = [ + requests.codes['internal_server_error'], + requests.codes['service_unavailable'], + requests.codes['request_timeout'], + requests.codes['gateway_timeout'], + requests.codes['precondition_failed'], +] + +LOG = logging.getLogger(__name__) + + +class VaultKeyManager(key_manager.KeyManager): + """Key Manager Interface that wraps the Vault REST API.""" + + _secret_type_dict = { + op_data.OpaqueData: 'opaque', + passphrase.Passphrase: 'passphrase', + pri_key.PrivateKey: 'private', + pub_key.PublicKey: 'public', + sym_key.SymmetricKey: 'symmetric', + x_509.X509: 'certificate'} + + def __init__(self, configuration): + self._conf = configuration + self._conf.register_opts(vault_opts, group=VAULT_OPT_GROUP) + loading.register_session_conf_options(self._conf, VAULT_OPT_GROUP) + self._session = requests.Session() + self._root_token_id = self._conf.vault.root_token_id + self._vault_url = self._conf.vault.vault_url + if self._vault_url.startswith("https://"): + self._verify_server = self._conf.vault.ssl_ca_crt_file or True + else: + self._verify_server = False + + def _get_url(self): + if not self._vault_url.endswith('/'): + self._vault_url += '/' + return self._vault_url + + def create_key_pair(self, context, algorithm, length, + expiration=None, name=None): + """Creates an asymmetric key pair.""" + raise NotImplementedError( + "VaultKeyManager does not support asymmetric keys") + + def _store_key_value(self, key_id, value): + + type_value = self._secret_type_dict.get(type(value)) + if type_value is None: + raise exception.KeyManagerError( + "Unknown type for value : %r" % value) + + headers = {'X-Vault-Token': self._root_token_id} + try: + resource_url = self._get_url() + 'v1/secret/' + key_id + record = { + 'type': type_value, + 'value': binascii.hexlify(value.get_encoded()).decode('utf-8'), + 'algorithm': (value.algorithm if hasattr(value, 'algorithm') + else None), + 'bit_length': (value.bit_length if hasattr(value, 'bit_length') + else None), + 'name': value.name, + 'created': value.created + } + resp = self._session.post(resource_url, + verify=self._verify_server, + json=record, + headers=headers) + except requests.exceptions.Timeout as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except requests.exceptions.ConnectionError as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except Exception as ex: + raise exception.KeyManagerError(six.text_type(ex)) + + if resp.status_code in _EXCEPTIONS_BY_CODE: + raise exception.KeyManagerError(resp.reason) + if resp.status_code == requests.codes['forbidden']: + raise exception.Forbidden() + + return key_id + + def create_key(self, context, algorithm, length, name=None, **kwargs): + """Creates a symmetric key.""" + + # Confirm context is provided, if not raise forbidden + if not context: + msg = _("User is not authorized to use key manager.") + raise exception.Forbidden(msg) + + key_id = uuid.uuid4().hex + key_value = os.urandom(length or 32) + key = sym_key.SymmetricKey(algorithm, + length or 32, + key_value, + key_id, + name or int(time.time())) + return self._store_key_value(key_id, key) + + def store(self, context, key_value, **kwargs): + """Stores (i.e., registers) a key with the key manager.""" + + # Confirm context is provided, if not raise forbidden + if not context: + msg = _("User is not authorized to use key manager.") + raise exception.Forbidden(msg) + + key_id = uuid.uuid4().hex + return self._store_key_value(key_id, key_value) + + def get(self, context, key_id, metadata_only=False): + """Retrieves the key identified by the specified id.""" + + # Confirm context is provided, if not raise forbidden + if not context: + msg = _("User is not authorized to use key manager.") + raise exception.Forbidden(msg) + + if not key_id: + raise exception.KeyManagerError('key identifier not provided') + + headers = {'X-Vault-Token': self._root_token_id} + try: + resource_url = self._get_url() + 'v1/secret/' + key_id + resp = self._session.get(resource_url, + verify=self._verify_server, + headers=headers) + except requests.exceptions.Timeout as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except requests.exceptions.ConnectionError as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except Exception as ex: + raise exception.KeyManagerError(six.text_type(ex)) + + if resp.status_code in _EXCEPTIONS_BY_CODE: + raise exception.KeyManagerError(resp.reason) + if resp.status_code == requests.codes['forbidden']: + raise exception.Forbidden() + if resp.status_code == requests.codes['not_found']: + raise exception.ManagedObjectNotFoundError(uuid=key_id) + + record = resp.json()['data'] + key = None if metadata_only else binascii.unhexlify(record['value']) + + clazz = None + for type_clazz, type_name in self._secret_type_dict.items(): + if type_name == record['type']: + clazz = type_clazz + + if clazz is None: + raise exception.KeyManagerError( + "Unknown type : %r" % record['type']) + + if hasattr(clazz, 'algorithm') and hasattr(clazz, 'bit_length'): + return clazz(record['algorithm'], + record['bit_length'], + key, + record['name'], + record['created'], + key_id) + else: + return clazz(key, + record['name'], + record['created'], + key_id) + + def delete(self, context, key_id): + """Represents deleting the key.""" + + # Confirm context is provided, if not raise forbidden + if not context: + msg = _("User is not authorized to use key manager.") + raise exception.Forbidden(msg) + + if not key_id: + raise exception.KeyManagerError('key identifier not provided') + + headers = {'X-Vault-Token': self._root_token_id} + try: + resource_url = self._get_url() + 'v1/secret/' + key_id + resp = self._session.delete(resource_url, + verify=self._verify_server, + headers=headers) + except requests.exceptions.Timeout as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except requests.exceptions.ConnectionError as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except Exception as ex: + raise exception.KeyManagerError(six.text_type(ex)) + + if resp.status_code in _EXCEPTIONS_BY_CODE: + raise exception.KeyManagerError(resp.reason) + if resp.status_code == requests.codes['forbidden']: + raise exception.Forbidden() + if resp.status_code == requests.codes['not_found']: + raise exception.ManagedObjectNotFoundError(uuid=key_id) + + def list(self, context, object_type=None, metadata_only=False): + """Lists the managed objects given the criteria.""" + + # Confirm context is provided, if not raise forbidden + if not context: + msg = _("User is not authorized to use key manager.") + raise exception.Forbidden(msg) + + if object_type and object_type not in self._secret_type_dict: + msg = _("Invalid secret type: %s") % object_type + raise exception.KeyManagerError(reason=msg) + + headers = {'X-Vault-Token': self._root_token_id} + try: + resource_url = self._get_url() + 'v1/secret/?list=true' + resp = self._session.get(resource_url, + verify=self._verify_server, + headers=headers) + keys = resp.json()['data']['keys'] + except requests.exceptions.Timeout as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except requests.exceptions.ConnectionError as ex: + raise exception.KeyManagerError(six.text_type(ex)) + except Exception as ex: + raise exception.KeyManagerError(six.text_type(ex)) + + if resp.status_code in _EXCEPTIONS_BY_CODE: + raise exception.KeyManagerError(resp.reason) + if resp.status_code == requests.codes['forbidden']: + raise exception.Forbidden() + if resp.status_code == requests.codes['not_found']: + keys = [] + + objects = [] + for obj_id in keys: + try: + obj = self.get(context, obj_id, metadata_only=metadata_only) + if object_type is None or isinstance(obj, object_type): + objects.append(obj) + except exception.ManagedObjectNotFoundError as e: + LOG.warning(_("Error occurred while retrieving object " + "metadata, not adding it to the list: %s"), e) + pass + return objects diff --git a/castellan/options.py b/castellan/options.py index 6f443446..e6bc2458 100644 --- a/castellan/options.py +++ b/castellan/options.py @@ -20,6 +20,12 @@ try: from castellan.key_manager import barbican_key_manager as bkm except ImportError: bkm = None + +try: + from castellan.key_manager import vault_key_manager as vkm +except ImportError: + vkm = None + from castellan.common import utils _DEFAULT_LOG_LEVELS = ['castellan=WARN'] @@ -33,7 +39,8 @@ _DEFAULT_LOGGING_CONTEXT_FORMAT = ('%(asctime)s.%(msecs)03d %(process)d ' def set_defaults(conf, backend=None, barbican_endpoint=None, barbican_api_version=None, auth_endpoint=None, retry_delay=None, number_of_retries=None, verify_ssl=None, - api_class=None): + api_class=None, vault_root_token_id=None, vault_url=None, + vault_ssl_ca_crt_file=None, vault_use_ssl=None): """Set defaults for configuration values. Overrides the default options values. @@ -45,10 +52,16 @@ def set_defaults(conf, backend=None, barbican_endpoint=None, :param retry_delay: Use this attribute to set retry delay. :param number_of_retries: Use this attribute to set number of retries. :param verify_ssl: Use this to specify if ssl should be verified. + :param vault_root_token_id: Use this for the root token id for vault. + :param vault_url: Use this for the url for vault. + :param vault_use_ssl: Use this to force vault driver to use ssl. + :param vault_ssl_ca_crt_file: Use this for the CA file for vault. """ conf.register_opts(km.key_manager_opts, group='key_manager') if bkm: conf.register_opts(bkm.barbican_opts, group=bkm.BARBICAN_OPT_GROUP) + if vkm: + conf.register_opts(vkm.vault_opts, group=vkm.VAULT_OPT_GROUP) # Use the new backend option if set or fall back to the older api_class default_backend = backend or api_class @@ -75,6 +88,20 @@ def set_defaults(conf, backend=None, barbican_endpoint=None, conf.set_default('verify_ssl', verify_ssl, group=bkm.BARBICAN_OPT_GROUP) + if vkm is not None: + if vault_root_token_id is not None: + conf.set_default('root_token_id', vault_root_token_id, + group=vkm.VAULT_OPT_GROUP) + if vault_url is not None: + conf.set_default('vault_url', vault_url, + group=vkm.VAULT_OPT_GROUP) + if vault_ssl_ca_crt_file is not None: + conf.set_default('ssl_ca_crt_file', vault_ssl_ca_crt_file, + group=vkm.VAULT_OPT_GROUP) + if vault_use_ssl is not None: + conf.set_default('use_ssl', vault_use_ssl, + group=vkm.VAULT_OPT_GROUP) + def enable_logging(conf=None, app_name='castellan'): conf = conf or cfg.CONF @@ -109,4 +136,6 @@ def list_opts(): if bkm is not None: opts.append((bkm.BARBICAN_OPT_GROUP, bkm.barbican_opts)) + if vkm is not None: + opts.append((vkm.VAULT_OPT_GROUP, vkm.vault_opts)) return opts diff --git a/castellan/tests/functional/key_manager/test_barbican_key_manager.py b/castellan/tests/functional/key_manager/test_barbican_key_manager.py index 7651821d..5fdd1a32 100644 --- a/castellan/tests/functional/key_manager/test_barbican_key_manager.py +++ b/castellan/tests/functional/key_manager/test_barbican_key_manager.py @@ -26,6 +26,7 @@ from oslo_config import cfg from oslo_context import context from oslo_utils import uuidutils from oslotest import base +from testtools import testcase from castellan.common.credentials import keystone_password from castellan.common.credentials import keystone_token @@ -50,7 +51,13 @@ class BarbicanKeyManagerTestCase(test_key_manager.KeyManagerTestCase): def setUp(self): super(BarbicanKeyManagerTestCase, self).setUp() - self.ctxt = self.get_context() + try: + self.ctxt = self.get_context() + self.key_mgr._get_barbican_client(self.ctxt) + except Exception as e: + # When we run functional-vault target, This test class needs + # to be skipped as barbican is not running + raise testcase.TestSkipped(str(e)) def tearDown(self): super(BarbicanKeyManagerTestCase, self).tearDown() diff --git a/castellan/tests/functional/key_manager/test_vault_key_manager.py b/castellan/tests/functional/key_manager/test_vault_key_manager.py new file mode 100644 index 00000000..e7e027bc --- /dev/null +++ b/castellan/tests/functional/key_manager/test_vault_key_manager.py @@ -0,0 +1,108 @@ +# 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. + +""" +Functional test cases for the Vault key manager. + +Note: This requires local running instance of Vault. +""" +import abc +import os + +from oslo_config import cfg +from oslo_context import context +from oslo_utils import uuidutils +from oslotest import base +from testtools import testcase + +from castellan.common import exception +from castellan.key_manager import vault_key_manager +from castellan.tests.functional import config +from castellan.tests.functional.key_manager import test_key_manager + +CONF = config.get_config() + + +class VaultKeyManagerTestCase(test_key_manager.KeyManagerTestCase): + def _create_key_manager(self): + key_mgr = vault_key_manager.VaultKeyManager(cfg.CONF) + + if ('VAULT_TEST_URL' not in os.environ or + 'VAULT_TEST_ROOT_TOKEN' not in os.environ): + raise testcase.TestSkipped('Missing Vault setup information') + + key_mgr._root_token_id = os.environ['VAULT_TEST_ROOT_TOKEN'] + key_mgr._vault_url = os.environ['VAULT_TEST_URL'] + return key_mgr + + @abc.abstractmethod + def get_context(self): + """Retrieves Context for Authentication""" + return + + def setUp(self): + super(VaultKeyManagerTestCase, self).setUp() + self.ctxt = self.get_context() + + def tearDown(self): + super(VaultKeyManagerTestCase, self).tearDown() + + def test_create_key_pair(self): + self.assertRaises(NotImplementedError, + self.key_mgr.create_key_pair, None, None, None) + + def test_create_null_context(self): + self.assertRaises(exception.Forbidden, + self.key_mgr.create_key, None, 'AES', 256) + + def test_create_key_pair_null_context(self): + self.assertRaises(NotImplementedError, + self.key_mgr.create_key_pair, None, 'RSA', 2048) + + def test_delete_null_context(self): + key_uuid = self._get_valid_object_uuid( + test_key_manager._get_test_symmetric_key()) + self.addCleanup(self.key_mgr.delete, self.ctxt, key_uuid) + self.assertRaises(exception.Forbidden, + self.key_mgr.delete, None, key_uuid) + + def test_delete_null_object(self): + self.assertRaises(exception.KeyManagerError, + self.key_mgr.delete, self.ctxt, None) + + def test_get_null_context(self): + key_uuid = self._get_valid_object_uuid( + test_key_manager._get_test_symmetric_key()) + self.addCleanup(self.key_mgr.delete, self.ctxt, key_uuid) + self.assertRaises(exception.Forbidden, + self.key_mgr.get, None, key_uuid) + + def test_get_null_object(self): + self.assertRaises(exception.KeyManagerError, + self.key_mgr.get, self.ctxt, None) + + def test_get_unknown_key(self): + bad_key_uuid = uuidutils.generate_uuid() + self.assertRaises(exception.ManagedObjectNotFoundError, + self.key_mgr.get, self.ctxt, bad_key_uuid) + + def test_store_null_context(self): + key = test_key_manager._get_test_symmetric_key() + + self.assertRaises(exception.Forbidden, + self.key_mgr.store, None, key) + + +class VaultKeyManagerOSLOContextTestCase(VaultKeyManagerTestCase, + base.BaseTestCase): + def get_context(self): + return context.get_admin_context() diff --git a/releasenotes/notes/add-vault-provider-29a4c19fe67ab51f.yaml b/releasenotes/notes/add-vault-provider-29a4c19fe67ab51f.yaml new file mode 100644 index 00000000..4f646f91 --- /dev/null +++ b/releasenotes/notes/add-vault-provider-29a4c19fe67ab51f.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Added a new provider for Vault (https://www.vaultproject.io/) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index e71a9df4..993a292c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ oslo.config.opts = castellan.drivers = barbican = castellan.key_manager.barbican_key_manager:BarbicanKeyManager + vault = castellan.key_manager.vault_key_manager:VaultKeyManager [build_sphinx] source-dir = doc/source diff --git a/tools/setup-vault-env.sh b/tools/setup-vault-env.sh new file mode 100755 index 00000000..decde255 --- /dev/null +++ b/tools/setup-vault-env.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -eux +if [ -z "$(which vault)" ]; then + VAULT_VERSION=0.7.3 + SUFFIX=zip + case `uname -s` in + Darwin) + OS=darwin + ;; + Linux) + OS=linux + ;; + *) + echo "Unsupported OS" + exit 1 + esac + case `uname -m` in + x86_64) + MACHINE=amd64 + ;; + *) + echo "Unsupported machine" + exit 1 + esac + TARBALL_NAME=vault_${VAULT_VERSION}_${OS}_${MACHINE} + test ! -d "$TARBALL_NAME" && mkdir ${TARBALL_NAME} && wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/${TARBALL_NAME}.${SUFFIX} && unzip -d ${TARBALL_NAME} ${TARBALL_NAME}.${SUFFIX} && rm ${TARBALL_NAME}.${SUFFIX} + export VAULT_CONFIG_PATH=$(pwd)/$TARBALL_NAME/vault.json + export PATH=$PATH:$(pwd)/$TARBALL_NAME +fi + +$* diff --git a/tox.ini b/tox.ini index 2667f687..7f8c4736 100644 --- a/tox.ini +++ b/tox.ini @@ -57,6 +57,18 @@ deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt commands = python setup.py testr --slowest --testr-args='{posargs}' +[testenv:functional-vault] +passenv = HOME +usedevelop = True +install_command = pip install -U {opts} {packages} +setenv = + VIRTUAL_ENV={envdir} + OS_TEST_PATH=./castellan/tests/functional +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + {toxinidir}/tools/setup-vault-env.sh pifpaf -e VAULT_TEST run vault -- python setup.py testr --slowest --testr-args='{posargs}' + [testenv:genconfig] commands = oslo-config-generator --config-file=etc/castellan/functional-config-generator.conf