From 9777be8f376316b513b2abb2a8b60a095b3d1621 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Fri, 7 Mar 2014 12:38:15 -0600 Subject: [PATCH] Add OS:Barbican:Secret resource This adds a Barbican Secret resource to contrib plugins allowing secrets to be created using barbican's infrastructure. Co-Authored-By: Anderson Mesquita Implements: blueprint barbican-resources Change-Id: Ie9d345b8a1186cd9e00f6a083652f4f0466e94c3 --- contrib/barbican/barbican/__init__.py | 0 contrib/barbican/barbican/clients.py | 42 +++++ .../barbican/barbican/resources/__init__.py | 0 contrib/barbican/barbican/resources/secret.py | 173 ++++++++++++++++++ contrib/barbican/barbican/tests/__init__.py | 0 .../barbican/barbican/tests/test_clients.py | 44 +++++ .../barbican/barbican/tests/test_secret.py | 159 ++++++++++++++++ contrib/barbican/requirements.txt | 1 + 8 files changed, 419 insertions(+) create mode 100644 contrib/barbican/barbican/__init__.py create mode 100644 contrib/barbican/barbican/clients.py create mode 100644 contrib/barbican/barbican/resources/__init__.py create mode 100644 contrib/barbican/barbican/resources/secret.py create mode 100644 contrib/barbican/barbican/tests/__init__.py create mode 100644 contrib/barbican/barbican/tests/test_clients.py create mode 100644 contrib/barbican/barbican/tests/test_secret.py create mode 100644 contrib/barbican/requirements.txt diff --git a/contrib/barbican/barbican/__init__.py b/contrib/barbican/barbican/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/barbican/barbican/clients.py b/contrib/barbican/barbican/clients.py new file mode 100644 index 0000000000..3137ce67f2 --- /dev/null +++ b/contrib/barbican/barbican/clients.py @@ -0,0 +1,42 @@ +# +# 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. + +from heat.engine import clients as heat_clients +from heat.openstack.common import log as logging + + +try: + from barbicanclient import client as barbican_client + from barbicanclient.common import auth +except ImportError: + barbican_client = None + auth = None + LOG = logging.getLogger(__name__) + LOG.warn(_("barbican plugin loaded, but " + "python-barbicanclient requirement not satisfied.")) + + +class Clients(heat_clients.OpenStackClients): + + def __init__(self, context): + super(Clients, self).__init__(context) + self._barbican = None + + def barbican(self): + if self._barbican: + return self._barbican + + keystone_client = self.keystone().client + auth_plugin = auth.KeystoneAuthV2(keystone=keystone_client) + self._barbican = barbican_client.Client(auth_plugin=auth_plugin) + return self._barbican diff --git a/contrib/barbican/barbican/resources/__init__.py b/contrib/barbican/barbican/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/barbican/barbican/resources/secret.py b/contrib/barbican/barbican/resources/secret.py new file mode 100644 index 0000000000..acc05930a1 --- /dev/null +++ b/contrib/barbican/barbican/resources/secret.py @@ -0,0 +1,173 @@ +# +# 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. + +from heat.common import exception +from heat.engine import attributes +from heat.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.openstack.common import log as logging + +from .. import clients # noqa + + +LOG = logging.getLogger(__name__) + + +class Secret(resource.Resource): + + PROPERTIES = ( + NAME, PAYLOAD, PAYLOAD_CONTENT_TYPE, PAYLOAD_CONTENT_ENCODING, + MODE, EXPIRATION, ALGORITHM, BIT_LENGTH, + ) = ( + 'name', 'payload', 'payload_content_type', 'payload_content_encoding', + 'mode', 'expiration', 'algorithm', 'bit_length', + ) + + ATTRIBUTES = ( + STATUS, DECRYPTED_PAYLOAD, + ) = ( + 'status', 'decrypted_payload', + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Human readable name for the secret.'), + ), + PAYLOAD: properties.Schema( + properties.Schema.STRING, + _('The unencrypted plain text of the secret.'), + ), + PAYLOAD_CONTENT_TYPE: properties.Schema( + properties.Schema.STRING, + _('The type/format the secret data is provided in.'), + constraints=[ + constraints.AllowedValues([ + 'text/plain', + 'application/octet-stream', + ]), + ], + ), + PAYLOAD_CONTENT_ENCODING: properties.Schema( + properties.Schema.STRING, + _('The encoding format used to provide the payload data.'), + default='base64', + constraints=[ + constraints.AllowedValues([ + 'base64', + ]), + ], + ), + EXPIRATION: properties.Schema( + properties.Schema.STRING, + _('The expiration date for the secret in ISO-8601 format.'), + constraints=[ + constraints.CustomConstraint('iso_8601'), + ], + ), + ALGORITHM: properties.Schema( + properties.Schema.STRING, + _('The algorithm type used to generate the secret.'), + ), + BIT_LENGTH: properties.Schema( + properties.Schema.NUMBER, + _('The bit-length of the secret.'), + constraints=[ + constraints.Range( + min=0, + ), + ], + ), + MODE: properties.Schema( + properties.Schema.STRING, + _('The type/mode of the algorithm associated with the secret ' + 'information.'), + ), + } + + attributes_schema = { + STATUS: attributes.Schema( + _('The status of the secret.') + ), + DECRYPTED_PAYLOAD: attributes.Schema( + _('The decrypted secret payload.') + ), + } + + def __init__(self, name, json_snippet, stack): + super(Secret, self).__init__(name, json_snippet, stack) + self.clients = clients.Clients(self.context) + + def validate(self): + super(Secret, self).validate() + self._validate_payload() + + def _validate_payload(self): + '''Payload is optional, but requires content type if provided.''' + + payload = self.properties.get(self.PAYLOAD) + content_type = self.properties.get(self.PAYLOAD_CONTENT_TYPE) + if bool(payload) != bool(content_type): + msg = _("'payload' and 'payload_content_type' must both be " + "provided or omitted.") + raise exception.StackValidationFailed(message=msg) + + def handle_create(self): + info = dict(self.properties) + secret_ref = self.clients.barbican().secrets.store(**info) + self.resource_id_set(secret_ref) + return secret_ref + + def handle_delete(self): + if not self.resource_id: + return + + try: + self.clients.barbican().secrets.delete(self.resource_id) + self.resource_id_set(None) + except clients.barbican_client.HTTPClientError as exc: + # This is the only exception the client raises + # Inspecting the message to see if it's a 'Not Found' + if 'Not Found' in str(exc): + self.resource_id_set(None) + else: + raise + + def _resolve_attribute(self, name): + try: + if name == self.DECRYPTED_PAYLOAD: + return self.clients.barbican().secrets.decrypt( + self.resource_id) + + secret = self.clients.barbican().secrets.get(self.resource_id) + if name == self.STATUS: + return secret.status + except clients.barbican_client.HTTPClientError as e: + msg = _("Failed to resolve '%(name)s' for %(res)s '%(id)s': %(e)s") + LOG.warn(msg % {'name': name, 'res': self.__class__.__name__, + 'id': self.resource_id, 'e': str(e)}) + return '' + + +def resource_mapping(): + return { + 'OS::Barbican::Secret': Secret, + } + + +def available_resource_mapping(): + if not clients.barbican_client: + return {} + + return resource_mapping() diff --git a/contrib/barbican/barbican/tests/__init__.py b/contrib/barbican/barbican/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/barbican/barbican/tests/test_clients.py b/contrib/barbican/barbican/tests/test_clients.py new file mode 100644 index 0000000000..e6ddb79e34 --- /dev/null +++ b/contrib/barbican/barbican/tests/test_clients.py @@ -0,0 +1,44 @@ +# +# 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 mock + +from heat.tests.common import HeatTestCase +from heat.tests import utils + +from .. import clients # noqa + + +class TestClient(HeatTestCase): + + def setUp(self): + super(TestClient, self).setUp() + self.ctx = utils.dummy_context() + self.clients = clients.Clients(self.ctx) + + @mock.patch.object(clients.heat_clients, 'Clients') + @mock.patch.object(clients, 'barbican_client') + @mock.patch.object(clients, 'auth') + def test_barbican_passes_in_heat_keystone_client(self, mock_auth, + mock_barbican_client, + mock_heat_clients): + mock_ks = mock.Mock() + self.clients.keystone = mock.Mock() + self.clients.keystone.return_value.client = mock_ks + mock_plugin = mock.Mock() + mock_auth.KeystoneAuthV2.return_value = mock_plugin + + self.clients.barbican() + mock_auth.KeystoneAuthV2.assert_called_once_with(keystone=mock_ks) + mock_barbican_client.Client.assert_called_once_with(auth_plugin= + mock_plugin) diff --git a/contrib/barbican/barbican/tests/test_secret.py b/contrib/barbican/barbican/tests/test_secret.py new file mode 100644 index 0000000000..eb26dd6ffb --- /dev/null +++ b/contrib/barbican/barbican/tests/test_secret.py @@ -0,0 +1,159 @@ +# +# 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 mock + +from heat.common import exception +from heat.common import template_format +from heat.engine import resource +from heat.engine import scheduler +from heat.tests.common import HeatTestCase +from heat.tests import utils + +from ..resources import secret # noqa + + +stack_template = ''' +heat_template_version: 2013-05-23 +description: Test template +resources: + secret: + type: OS::Barbican::Secret + properties: + name: foobar-secret +''' + + +class TestSecret(HeatTestCase): + + def setUp(self): + super(TestSecret, self).setUp() + utils.setup_dummy_db() + self.ctx = utils.dummy_context() + + self.patcher_client = mock.patch.object(secret.clients, 'Clients') + mock_client = self.patcher_client.start() + self.barbican = mock_client.return_value.barbican.return_value + + self._register_resources() + self.stack = utils.parse_stack(template_format.parse(stack_template)) + self.stack.validate() + self.res_template = self.stack.t['resources']['secret'] + self.res = self._create_resource('foo', self.res_template, self.stack) + + def tearDown(self): + super(TestSecret, self).tearDown() + self.patcher_client.stop() + + def _register_resources(self): + for res_name, res_class in secret.resource_mapping().iteritems(): + resource._register_class(res_name, res_class) + + def _create_resource(self, name, snippet, stack): + res = secret.Secret(name, snippet, stack) + self.barbican.secrets.store.return_value = name + '_id' + scheduler.TaskRunner(res.create)() + return res + + def test_create_secret(self): + expected_state = (self.res.CREATE, self.res.COMPLETE) + self.assertEqual(expected_state, self.res.state) + args = self.barbican.secrets.store.call_args[1] + self.assertEqual('foobar-secret', args['name']) + + def test_attributes(self): + mock_secret = mock.Mock() + mock_secret.status = 'test-status' + self.barbican.secrets.get.return_value = mock_secret + self.barbican.secrets.decrypt.return_value = 'foo' + + self.assertEqual('test-status', self.res.FnGetAtt('status')) + self.assertEqual('foo', self.res.FnGetAtt('decrypted_payload')) + + @mock.patch.object(secret.clients, 'barbican_client', new=mock.Mock()) + def test_attributes_handles_exceptions(self): + secret.clients.barbican_client.HTTPClientError = Exception + some_error = secret.clients.barbican_client.HTTPClientError('boom') + secret.clients.Clients().barbican.side_effect = some_error + + self.assertEqual('', self.res.FnGetAtt('status')) + + def test_create_secret_sets_resource_id(self): + self.assertEqual('foo_id', self.res.resource_id) + + def test_create_secret_with_plain_text(self): + content_type = 'text/plain' + self.res_template['Properties']['payload'] = 'foobar' + self.res_template['Properties']['payload_content_type'] = content_type + res = self._create_resource('secret', self.res_template, self.stack) + + args = self.barbican.secrets.store.call_args[1] + self.assertEqual('foobar', args[res.PAYLOAD]) + self.assertEqual(content_type, args[res.PAYLOAD_CONTENT_TYPE]) + + def test_create_secret_with_octet_stream(self): + content_type = 'application/octet-stream' + self.res_template['Properties']['payload'] = 'foobar' + self.res_template['Properties']['payload_content_type'] = content_type + res = self._create_resource('secret', self.res_template, self.stack) + + args = self.barbican.secrets.store.call_args[1] + self.assertEqual('foobar', args[res.PAYLOAD]) + self.assertEqual(content_type, args[res.PAYLOAD_CONTENT_TYPE]) + + def test_create_secret_other_content_types_not_allowed(self): + self.res_template['Properties']['payload_content_type'] = 'not/allowed' + self.assertRaises(exception.ResourceFailure, + self._create_resource, 'secret', self.res_template, + self.stack) + + def test_validate_payload_and_content_type(self): + self.res_template['Properties'] = {} + self.res_template['Properties']['payload_content_type'] = 'text/plain' + res = self._create_resource('nopayload', self.res_template, self.stack) + exc = self.assertRaises(exception.StackValidationFailed, res.validate) + self.assertIn('payload', str(exc)) + self.assertIn('payload_content_type', str(exc)) + + self.res_template['Properties'] = {} + self.res_template['Properties']['payload'] = 'foo' + res = self._create_resource('notype', self.res_template, self.stack) + exc = self.assertRaises(exception.StackValidationFailed, res.validate) + self.assertIn('payload', str(exc)) + self.assertIn('payload_content_type', str(exc)) + + def test_delete_secret(self): + self.assertEqual('foo_id', self.res.resource_id) + + mock_delete = self.barbican.secrets.delete + scheduler.TaskRunner(self.res.delete)() + + self.assertIsNone(self.res.resource_id) + mock_delete.assert_called_once_with('foo_id') + + @mock.patch.object(secret.clients, 'barbican_client', new=mock.Mock()) + def test_handle_delete_ignores_not_found_errors(self): + secret.clients.barbican_client.HTTPClientError = Exception + exc = secret.clients.barbican_client.HTTPClientError('Not Found.') + self.barbican.secrets.delete.side_effect = exc + scheduler.TaskRunner(self.res.delete)() + self.assertTrue(self.barbican.secrets.delete.called) + + @mock.patch.object(secret.clients, 'barbican_client', new=mock.Mock()) + def test_handle_delete_raises_resource_failure_on_error(self): + secret.clients.barbican_client.HTTPClientError = Exception + exc = secret.clients.barbican_client.HTTPClientError('Boom.') + self.barbican.secrets.delete.side_effect = exc + exc = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(self.res.delete)) + self.assertIn('Boom.', str(exc)) diff --git a/contrib/barbican/requirements.txt b/contrib/barbican/requirements.txt new file mode 100644 index 0000000000..fda243ea5a --- /dev/null +++ b/contrib/barbican/requirements.txt @@ -0,0 +1 @@ +python-barbicanclient>=2.0.0