diff --git a/.gitignore b/.gitignore index 3edc8ef..304efbf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ .stestr/ .venv cover +vault_* # Translations *.mo diff --git a/castellan/_config_driver.py b/castellan/_config_driver.py new file mode 100644 index 0000000..26fdb0a --- /dev/null +++ b/castellan/_config_driver.py @@ -0,0 +1,141 @@ +# 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. + +r""" +Castellan Oslo Config Driver +---------------------------- + +This driver is an oslo.config backend driver implemented with Castellan. It +extends oslo.config's capabilities by enabling it to retrieve configuration +values from a secret manager behind Castellan. + +The setup of a Castellan configuration source is as follow:: + + [DEFAULT] + config_source = castellan_config_group + + [castellan_config_group] + driver = castellan + config_file = castellan.conf + mapping_file = mapping.conf + +In the following sessions, you can find more information about this driver's +classes and its options. + +The Driver Class +================ + +.. autoclass:: CastellanConfigurationSourceDriver + +The Configuration Source Class +============================== + +.. autoclass:: CastellanConfigurationSource + +""" +from castellan.common.exception import KeyManagerError +from castellan.common.exception import ManagedObjectNotFoundError +from castellan import key_manager + +from oslo_config import cfg +from oslo_config import sources +from oslo_log import log + +LOG = log.getLogger(__name__) + + +class CastellanConfigurationSourceDriver(sources.ConfigurationSourceDriver): + """A backend driver for configuration values served through castellan. + + Required options: + - config_file: The castellan configuration file. + + - mapping_file: A configuration/castellan_id mapping file. This file + creates connections between configuration options and + castellan ids. The group and option name remains the + same, while the value gets stored a secret manager behind + castellan and is replaced by its castellan id. The ids + will be used to fetch the values through castellan. + """ + + _castellan_driver_opts = [ + cfg.StrOpt( + 'config_file', + required=True, + sample_default='etc/castellan/castellan.conf', + help=('The path to a castellan configuration file.'), + ), + cfg.StrOpt( + 'mapping_file', + required=True, + sample_default='etc/castellan/secrets_mapping.conf', + help=('The path to a configuration/castellan_id mapping file.'), + ), + ] + + def list_options_for_discovery(self): + return self._castellan_driver_opts + + def open_source_from_opt_group(self, conf, group_name): + conf.register_opts(self._castellan_driver_opts, group_name) + + return CastellanConfigurationSource( + group_name, + conf[group_name].config_file, + conf[group_name].mapping_file) + + +class CastellanConfigurationSource(sources.ConfigurationSource): + """A configuration source for configuration values served through castellan. + + :param config_file: The path to a castellan configuration file. + + :param mapping_file: The path to a configuration/castellan_id mapping file. + """ + + def __init__(self, group_name, config_file, mapping_file): + conf = cfg.ConfigOpts() + conf(args=[], default_config_files=[config_file]) + + self._name = group_name + self._mngr = key_manager.API(conf) + self._mapping = {} + + cfg.ConfigParser(mapping_file, self._mapping).parse() + + def get(self, group_name, option_name, opt): + try: + group_name = group_name or "DEFAULT" + + castellan_id = self._mapping[group_name][option_name][0] + + return (self._mngr.get("ctx", castellan_id).get_encoded().decode(), + cfg.LocationInfo(cfg.Locations.user, castellan_id)) + + except KeyError: + # no mapping 'option = castellan_id' + LOG.info("option '[%s] %s' not present in '[%s] mapping_file'", + group_name, option_name, self._name) + + except KeyManagerError: + # bad mapping 'option =' without a castellan_id + LOG.warning("missing castellan_id for option " + "'[%s] %s' in '[%s] mapping_file'", + group_name, option_name, self._name) + + except ManagedObjectNotFoundError: + # good mapping, but unknown castellan_id by secret manager + LOG.warning("invalid castellan_id for option " + "'[%s] %s' in '[%s] mapping_file'", + group_name, option_name, self._name) + + return (sources._NoValue, None) diff --git a/castellan/tests/unit/test_config_driver.py b/castellan/tests/unit/test_config_driver.py new file mode 100644 index 0000000..5b3275d --- /dev/null +++ b/castellan/tests/unit/test_config_driver.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 Castellan Oslo Config Driver. + +Note: This requires local running instance of Vault. +""" +import tempfile + +from oslo_config import cfg +from oslo_config import fixture + +from oslotest import base + +from castellan import _config_driver +from castellan.common.objects import opaque_data +from castellan.tests.unit.key_manager import fake + + +class CastellanSourceTestCase(base.BaseTestCase): + + def setUp(self): + super(CastellanSourceTestCase, self).setUp() + self.driver = _config_driver.CastellanConfigurationSourceDriver() + self.conf = cfg.ConfigOpts() + self.conf_fixture = self.useFixture(fixture.Config(self.conf)) + + def test_incomplete_driver(self): + # The group exists, but does not specify the + # required options for this driver. + self.conf_fixture.load_raw_values( + group='incomplete_driver', + driver='castellan', + ) + source = self.conf._open_source_from_opt_group('incomplete_driver') + + self.assertIsNone(source) + self.assertEqual(self.conf.incomplete_driver.driver, 'castellan') + + def test_complete_driver(self): + self.conf_fixture.load_raw_values( + group='castellan_source', + driver='castellan', + config_file='config.conf', + mapping_file='mapping.conf', + ) + + with base.mock.patch.object( + _config_driver, + 'CastellanConfigurationSource') as source_class: + self.driver.open_source_from_opt_group( + self.conf, 'castellan_source') + + source_class.assert_called_once_with( + 'castellan_source', + self.conf.castellan_source.config_file, + self.conf.castellan_source.mapping_file) + + def test_fetch_secret(self): + # fake KeyManager populated with secret + km = fake.fake_api() + secret_id = km.store("fake_context", + opaque_data.OpaqueData(b"super_secret!")) + + # driver config + config = "[key_manager]\nbackend=vault" + mapping = "[DEFAULT]\nmy_secret=" + secret_id + + # creating temp files + with tempfile.NamedTemporaryFile() as config_file: + config_file.write(config.encode("utf-8")) + config_file.flush() + + with tempfile.NamedTemporaryFile() as mapping_file: + mapping_file.write(mapping.encode("utf-8")) + mapping_file.flush() + + self.conf_fixture.load_raw_values( + group='castellan_source', + driver='castellan', + config_file=config_file.name, + mapping_file=mapping_file.name, + ) + + source = self.driver.open_source_from_opt_group( + self.conf, + 'castellan_source') + + # replacing key_manager with fake one + source._mngr = km + + # testing if the source is able to retrieve + # the secret value stored in the key_manager + # using the secret_id from the mapping file + self.assertEqual("super_secret!", + source.get("DEFAULT", + "my_secret", + cfg.StrOpt(""))[0]) diff --git a/lower-constraints.txt b/lower-constraints.txt index b51623b..3432ac9 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -33,7 +33,7 @@ netifaces==0.10.4 openstackdocstheme==1.18.1 os-client-config==1.28.0 -oslo.config==5.2.0 +oslo.config==6.4.0 oslo.context==2.19.2 oslo.i18n==3.15.3 oslo.log==3.36.0 diff --git a/requirements.txt b/requirements.txt index a6020f7..eeeb417 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ Babel!=2.4.0,>=2.3.4 # BSD cryptography>=2.1 # BSD/Apache-2.0 python-barbicanclient>=4.5.2 # Apache-2.0 -oslo.config>=5.2.0 # Apache-2.0 +oslo.config>=6.4.0 # Apache-2.0 oslo.context>=2.19.2 # Apache-2.0 oslo.i18n>=3.15.3 # Apache-2.0 oslo.log>=3.36.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 993a292..63bacbe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,9 @@ oslo.config.opts = castellan.tests.functional.config = castellan.tests.functional.config:list_opts castellan.config = castellan.options:list_opts + +oslo.config.driver = + castellan = castellan._config_driver:CastellanConfigurationSourceDriver castellan.drivers = barbican = castellan.key_manager.barbican_key_manager:BarbicanKeyManager diff --git a/test-requirements.txt b/test-requirements.txt index b35c1cb..918095d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,6 +10,7 @@ openstackdocstheme>=1.18.1 # Apache-2.0 oslotest>=3.2.0 # Apache-2.0 stestr>=2.0.0 # Apache-2.0 +fixtures>=3.0.0 # Apache-2.0/BSD testscenarios>=0.4 # Apache-2.0/BSD testtools>=2.2.0 # MIT bandit>=1.1.0 # Apache-2.0