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 <andersonvom@gmail.com>
Implements: blueprint barbican-resources
Change-Id: Ie9d345b8a1186cd9e00f6a083652f4f0466e94c3
This commit is contained in:
Richard Lee 2014-03-07 12:38:15 -06:00 committed by Anderson Mesquita
parent 0627b5bbf1
commit 9777be8f37
8 changed files with 419 additions and 0 deletions

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
python-barbicanclient>=2.0.0