From 21504848d687e998b68c52b5824c940a87d0cfb7 Mon Sep 17 00:00:00 2001 From: Kaitlin Farr Date: Tue, 14 Nov 2017 12:01:29 -0500 Subject: [PATCH] Add Opaque Data Panel Change-Id: I66e1dd24e831c7e1fb59d24ba46a9d0ff1d85b76 --- README.rst | 3 +- castellan_ui/content/opaque_data/__init__.py | 0 castellan_ui/content/opaque_data/forms.py | 108 +++++++++++++++ castellan_ui/content/opaque_data/panel.py | 23 ++++ castellan_ui/content/opaque_data/tables.py | 84 ++++++++++++ castellan_ui/content/opaque_data/urls.py | 26 ++++ castellan_ui/content/opaque_data/views.py | 124 ++++++++++++++++++ ...5_project_key_manager_opaque_data_panel.py | 23 ++++ .../templates/_opaque_data_import.html | 9 ++ castellan_ui/templates/opaque_data.html | 23 ++++ .../templates/opaque_data_detail.html | 24 ++++ .../templates/opaque_data_import.html | 7 + .../test/content/opaque_data/__init__.py | 0 .../test/content/opaque_data/tests.py | 109 +++++++++++++++ castellan_ui/test/test_data.py | 12 ++ 15 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 castellan_ui/content/opaque_data/__init__.py create mode 100644 castellan_ui/content/opaque_data/forms.py create mode 100644 castellan_ui/content/opaque_data/panel.py create mode 100644 castellan_ui/content/opaque_data/tables.py create mode 100644 castellan_ui/content/opaque_data/urls.py create mode 100644 castellan_ui/content/opaque_data/views.py create mode 100644 castellan_ui/enabled/_95_project_key_manager_opaque_data_panel.py create mode 100644 castellan_ui/templates/_opaque_data_import.html create mode 100644 castellan_ui/templates/opaque_data.html create mode 100644 castellan_ui/templates/opaque_data_detail.html create mode 100644 castellan_ui/templates/opaque_data_import.html create mode 100644 castellan_ui/test/content/opaque_data/__init__.py create mode 100644 castellan_ui/test/content/opaque_data/tests.py diff --git a/README.rst b/README.rst index e747721..bf5bc16 100644 --- a/README.rst +++ b/README.rst @@ -57,7 +57,8 @@ And enable it in Horizon:: ln -s ../castellan-ui/castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py openstack_dashboard/local/enabled ln -s ../castellan-ui/castellan_ui/enabled/_92_project_key_manager_private_key_panel.py openstack_dashboard/local/enabled ln -s ../castellan-ui/castellan_ui/enabled/_93_project_key_manager_public_key_panel.py openstack_dashboard/local/enabled - ln -s ../castellan-ui/castellan_ui/enabled/_93_project_key_manager_symmetric_key_panel.py openstack_dashboard/local/enabled + ln -s ../castellan-ui/castellan_ui/enabled/_94_project_key_manager_symmetric_key_panel.py openstack_dashboard/local/enabled + ln -s ../castellan-ui/castellan_ui/enabled/_95_project_key_manager_opaque_data_panel.py openstack_dashboard/local/enabled To run horizon with the newly enabled Castellan UI plugin run:: diff --git a/castellan_ui/content/opaque_data/__init__.py b/castellan_ui/content/opaque_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/castellan_ui/content/opaque_data/forms.py b/castellan_ui/content/opaque_data/forms.py new file mode 100644 index 0000000..a323c7b --- /dev/null +++ b/castellan_ui/content/opaque_data/forms.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. + + +import base64 +import binascii +from django.utils.translation import ugettext_lazy as _ + +from castellan.common.objects import opaque_data +from horizon import exceptions +from horizon import forms +from horizon import messages + +from castellan_ui.api import client +from castellan_ui.content import shared_forms + + +class ImportOpaqueData(forms.SelfHandlingForm): + name = forms.RegexField(required=False, + max_length=255, + label=_("Data Name"), + regex=shared_forms.NAME_REGEX, + error_messages=shared_forms.ERROR_MESSAGES) + source_type = forms.ChoiceField( + label=_('Source'), + required=False, + choices=[('file', _('File')), + ('raw', _('Direct Input'))], + widget=forms.ThemableSelectWidget( + attrs={'class': 'switchable', 'data-slug': 'source'})) + object_file = forms.FileField( + label=_("Choose file"), + help_text=_("A local file to upload."), + widget=forms.FileInput( + attrs={'class': 'switched', 'data-switch-on': 'source', + 'data-source-file': _('File')}), + required=False) + direct_input = forms.CharField( + label=_('Object Bytes'), + help_text=_('The bytes of the object, represented in hex.'), + widget=forms.widgets.Textarea( + attrs={'class': 'switched', 'data-switch-on': 'source', + 'data-source-raw': _('Bytes')}), + required=False) + + def __init__(self, request, *args, **kwargs): + super(ImportOpaqueData, self).__init__(request, *args, **kwargs) + + def clean(self): + data = super(ImportOpaqueData, self).clean() + + # The data can be missing based on particular upload + # conditions. Code defensively for it here... + data_file = data.get('object_file', None) + data_raw = data.get('direct_input', None) + + if data_raw and data_file: + raise forms.ValidationError( + _("Cannot specify both file and direct input.")) + if not data_raw and not data_file: + raise forms.ValidationError( + _("No input was provided for the object value.")) + try: + if data_file: + data_bytes = self.files['object_file'].read() + else: + data_str = data['direct_input'] + data_bytes = binascii.unhexlify(data_str) + data['object_bytes'] = base64.b64encode(data_bytes) + except Exception as e: + msg = _('There was a problem loading the object: %s. ' + 'Is the object valid and in the correct format?') % e + raise forms.ValidationError(msg) + + return data + + def handle(self, request, data): + try: + data_bytes = data.get('object_bytes') + data_uuid = client.import_object( + request, + data=data_bytes, + name=data['name'], + object_type=opaque_data.OpaqueData) + + if data['name']: + data_identifier = data['name'] + else: + data_identifier = data_uuid + messages.success(request, + _('Successfully imported object: %s') + % data_identifier) + return data_uuid + except Exception as e: + msg = _('Unable to import object: %s') + messages.error(msg % e) + exceptions.handle(request, ignore=True) + self.api_error(_('Unable to import object.')) + return False diff --git a/castellan_ui/content/opaque_data/panel.py b/castellan_ui/content/opaque_data/panel.py new file mode 100644 index 0000000..5725252 --- /dev/null +++ b/castellan_ui/content/opaque_data/panel.py @@ -0,0 +1,23 @@ +# 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 django.utils.translation import ugettext_lazy as _ +import horizon + +# This panel will be loaded from horizon, because specified in enabled file. +# To register REST api, import below here. +from castellan_ui.api import client # noqa: F401 + + +class OpaqueData(horizon.Panel): + name = _("Opaque Data") + slug = "opaque_data" diff --git a/castellan_ui/content/opaque_data/tables.py b/castellan_ui/content/opaque_data/tables.py new file mode 100644 index 0000000..4129431 --- /dev/null +++ b/castellan_ui/content/opaque_data/tables.py @@ -0,0 +1,84 @@ +# 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 castellan_ui.content import filters +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from castellan_ui.api import client +from horizon import tables + + +class ImportOpaqueData(tables.LinkAction): + name = "import_opaque_data" + verbose_name = _("Import Opaque Data") + url = "horizon:project:opaque_data:import" + classes = ("ajax-modal",) + icon = "upload" + policy_rules = () + + +class DownloadOpaqueData(tables.LinkAction): + name = "download" + verbose_name = _("Download Opaque Data") + url = "horizon:project:opaque_data:download" + classes = ("btn-download",) + policy_rules = () + + def get_link_url(self, datum): + return reverse(self.url, + kwargs={'object_id': datum.id}) + + +class DeleteOpaqueData(tables.DeleteAction): + policy_rules = () + help_text = _("You should not delete an object unless you are " + "certain it is not being used anywhere.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Opaque Data", + u"Delete Opaque Data", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Deleted Opaque Data", + u"Deleted Opaque Data", + count + ) + + def delete(self, request, obj_id): + client.delete(request, obj_id) + + +class OpaqueDataTable(tables.DataTable): + detail_link = "horizon:project:opaque_data:detail" + uuid = tables.Column("id", verbose_name=_("Object ID"), link=detail_link) + name = tables.Column("name", verbose_name=_("Name")) + created_date = tables.Column("created", + verbose_name=_("Created Date"), + filters=(filters.timestamp_to_iso,)) + + def get_object_display(self, datum): + return datum.name if datum.name else datum.id + + class Meta(object): + name = "opaque_data" + table_actions = (ImportOpaqueData, + DeleteOpaqueData,) + row_actions = (DownloadOpaqueData, DeleteOpaqueData) diff --git a/castellan_ui/content/opaque_data/urls.py b/castellan_ui/content/opaque_data/urls.py new file mode 100644 index 0000000..5e77c40 --- /dev/null +++ b/castellan_ui/content/opaque_data/urls.py @@ -0,0 +1,26 @@ +# 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 castellan_ui.content.opaque_data import views +from django.conf.urls import url + +urlpatterns = [ + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^import/$', views.ImportView.as_view(), name='import'), + url(r'^(?P[^/]+)/$', + views.DetailView.as_view(), + name='detail'), + url(r'^download/$', views.download_key, name='download'), + url(r'^(?P[^/]+)/download$', + views.download_key, + name='download'), +] diff --git a/castellan_ui/content/opaque_data/views.py b/castellan_ui/content/opaque_data/views.py new file mode 100644 index 0000000..1e1dae0 --- /dev/null +++ b/castellan_ui/content/opaque_data/views.py @@ -0,0 +1,124 @@ +# 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 django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.http import HttpResponse +from django.utils.translation import ugettext_lazy as _ + +import binascii +from castellan.common.objects import opaque_data +from castellan_ui.api import client +from castellan_ui.content.opaque_data import forms as opaque_data_forms +from castellan_ui.content.opaque_data import tables +from datetime import datetime +from horizon import exceptions +from horizon import forms +from horizon.tables import views as tables_views +from horizon.utils import memoized +from horizon import views + + +def download_key(request, object_id): + try: + obj = client.get(request, object_id) + data = obj.get_encoded() + response = HttpResponse() + response.write(data) + response['Content-Disposition'] = ('attachment; ' + 'filename="%s.opaque"' % object_id) + response['Content-Length'] = str(len(response.content)) + return response + + except Exception: + redirect = reverse('horizon:project:opaque_data:index') + msg = _('Unable to download opaque_data "%s".')\ + % (object_id) + exceptions.handle(request, msg, redirect=redirect) + + +class IndexView(tables_views.MultiTableView): + table_classes = [ + tables.OpaqueDataTable + ] + template_name = 'opaque_data.html' + + def get_opaque_data_data(self): + try: + return client.list( + self.request, object_type=opaque_data.OpaqueData) + except Exception as e: + msg = _('Unable to list objects: "%s".') % (e.message) + exceptions.handle(self.request, msg) + return [] + + +class ImportView(forms.ModalFormView): + form_class = opaque_data_forms.ImportOpaqueData + template_name = 'opaque_data_import.html' + submit_url = reverse_lazy( + "horizon:project:opaque_data:import") + success_url = reverse_lazy('horizon:project:opaque_data:index') + submit_label = page_title = _("Import Opaque Data") + + def get_object_id(self, key_uuid): + return key_uuid + + +class DetailView(views.HorizonTemplateView): + template_name = 'opaque_data_detail.html' + page_title = _("Opaque Data Details") + + @memoized.memoized_method + def _get_data(self): + try: + obj = client.get(self.request, self.kwargs['object_id']) + except Exception: + redirect = reverse('horizon:project:opaque_data:index') + msg = _('Unable to retrieve details for opaque_data "%s".')\ + % (self.kwargs['object_id']) + exceptions.handle(self.request, msg, + redirect=redirect) + return obj + + @memoized.memoized_method + def _get_data_created_date(self, obj): + try: + created_date = datetime.utcfromtimestamp(obj.created).isoformat() + except Exception: + redirect = reverse('horizon:project:opaque_data:index') + msg = _('Unable to retrieve details for opaque_data "%s".')\ + % (self.kwargs['object_id']) + exceptions.handle(self.request, msg, + redirect=redirect) + return created_date + + @memoized.memoized_method + def _get_data_bytes(self, obj): + try: + data_bytes = binascii.hexlify(obj.get_encoded()) + except Exception: + redirect = reverse('horizon:project:opaque_data:index') + msg = _('Unable to retrieve details for opaque_data "%s".')\ + % (self.kwargs['object_id']) + exceptions.handle(self.request, msg, + redirect=redirect) + return data_bytes + + def get_context_data(self, **kwargs): + """Gets the context data for key.""" + context = super(DetailView, self).get_context_data(**kwargs) + obj = self._get_data() + context['object'] = obj + context['object_created_date'] = self._get_data_created_date(obj) + context['object_bytes'] = self._get_data_bytes(obj) + return context diff --git a/castellan_ui/enabled/_95_project_key_manager_opaque_data_panel.py b/castellan_ui/enabled/_95_project_key_manager_opaque_data_panel.py new file mode 100644 index 0000000..5641277 --- /dev/null +++ b/castellan_ui/enabled/_95_project_key_manager_opaque_data_panel.py @@ -0,0 +1,23 @@ +# 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. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'opaque_data' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'key_manager' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'project' + +ADD_INSTALLED_APP = ['castellan_ui', ] + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'castellan_ui.content.opaque_data.panel.OpaqueData' diff --git a/castellan_ui/templates/_opaque_data_import.html b/castellan_ui/templates/_opaque_data_import.html new file mode 100644 index 0000000..c3d4074 --- /dev/null +++ b/castellan_ui/templates/_opaque_data_import.html @@ -0,0 +1,9 @@ +{% extends '_object_import.html' %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "When importing your object as a file, the raw bytes of the file will be the raw bytes of the object. If you open the file using a text editor, it may not be human-readable because the bytes may not map to ASCII characters." %}

+

{% trans "To import your object using direct input, use the hex dump of the value of the object. For example, it may look like this:" %}

+

00112233445566778899aabbccddeeff

+{% endblock %} + diff --git a/castellan_ui/templates/opaque_data.html b/castellan_ui/templates/opaque_data.html new file mode 100644 index 0000000..fa740be --- /dev/null +++ b/castellan_ui/templates/opaque_data.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Opaque Data" %}{% endblock %} + +{% block breadcrumb_nav %} + +{% endblock %} + +{% block page_header %} + +{% endblock page_header %} + +{% block main %} +
+
+ {{ opaque_data_table.render }} +
+
+{% endblock %} diff --git a/castellan_ui/templates/opaque_data_detail.html b/castellan_ui/templates/opaque_data_detail.html new file mode 100644 index 0000000..6e28ecf --- /dev/null +++ b/castellan_ui/templates/opaque_data_detail.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load i18n parse_date %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_detail_header.html" %} +{% endblock %} + +{% block main %} +
+
+
{% trans "Name" %}
+
{{ object.name|default:_("None") }}
+
{% trans "Created" %}
+
{{ object_created_date|parse_date}}
+
{% trans "Object Value (in hex)" %}
+
+
{{ object_bytes|default:_("None") }}
+
+ +
+
+{% endblock %} diff --git a/castellan_ui/templates/opaque_data_import.html b/castellan_ui/templates/opaque_data_import.html new file mode 100644 index 0000000..8c41592 --- /dev/null +++ b/castellan_ui/templates/opaque_data_import.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include '_opaque_data_import.html' %} +{% endblock %} diff --git a/castellan_ui/test/content/opaque_data/__init__.py b/castellan_ui/test/content/opaque_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/castellan_ui/test/content/opaque_data/tests.py b/castellan_ui/test/content/opaque_data/tests.py new file mode 100644 index 0000000..c108964 --- /dev/null +++ b/castellan_ui/test/content/opaque_data/tests.py @@ -0,0 +1,109 @@ +# 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 base64 +import binascii +from django.core.handlers import wsgi +from django.core.urlresolvers import reverse +from horizon import messages as horizon_messages +import mock + +from castellan.common.objects import opaque_data +from castellan_ui.api import client as api_castellan +from castellan_ui.test import helpers as tests +from castellan_ui.test import test_data + +INDEX_URL = reverse('horizon:project:opaque_data:index') + + +class OpaqueDataViewTest(tests.APITestCase): + + def setUp(self): + super(OpaqueDataViewTest, self).setUp() + self.data = test_data.opaque_data + self.data_b64_bytes = base64.b64encode(self.data.get_encoded()) + self.mock_object( + api_castellan, "get", mock.Mock(return_value=self.data)) + self.mock_object(api_castellan, "list", mock.Mock(return_value=[])) + self.mock_object(horizon_messages, "success") + FAKE_ENVIRON = {'REQUEST_METHOD': 'GET', 'wsgi.input': 'fake_input'} + self.request = wsgi.WSGIRequest(FAKE_ENVIRON) + + def test_index(self): + data_list = [test_data.opaque_data, test_data.nameless_opaque_data] + + self.mock_object( + api_castellan, "list", mock.Mock(return_value=data_list)) + + res = self.client.get(INDEX_URL) + self.assertEqual(res.status_code, 200) + self.assertTemplateUsed(res, 'opaque_data.html') + api_castellan.list.assert_called_with( + mock.ANY, object_type=opaque_data.OpaqueData) + + def test_detail_view(self): + url = reverse('horizon:project:opaque_data:detail', + args=[self.data.id]) + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.data])) + self.mock_object( + api_castellan, "get", mock.Mock(return_value=self.data)) + + res = self.client.get(url) + self.assertContains( + res, "
Name
\n
%s
" % self.data.name, 1, 200) + api_castellan.get.assert_called_once_with(mock.ANY, self.data.id) + + def test_import_data(self): + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.data])) + url = reverse('horizon:project:opaque_data:import') + self.mock_object( + api_castellan, "import_object", mock.Mock(return_value=self.data)) + + data_input = ( + binascii.hexlify(self.data.get_encoded()).decode('utf-8') + ) + + data_form_data = { + 'source_type': 'raw', + 'name': self.data.name, + 'direct_input': data_input, + } + + self.client.post(url, data_form_data) + + api_castellan.import_object.assert_called_once_with( + mock.ANY, + object_type=opaque_data.OpaqueData, + data=self.data_b64_bytes, + name=self.data.name, + ) + + def test_delete_data(self): + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.data])) + self.mock_object(api_castellan, "delete") + + data_form_data = { + 'action': 'opaque_data__delete__%s' % self.data.id + } + + res = self.client.post(INDEX_URL, data_form_data) + + api_castellan.list.assert_called_with( + mock.ANY, object_type=opaque_data.OpaqueData) + api_castellan.delete.assert_called_once_with( + mock.ANY, + self.data.id, + ) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/castellan_ui/test/test_data.py b/castellan_ui/test/test_data.py index 3261c55..675d5a4 100644 --- a/castellan_ui/test/test_data.py +++ b/castellan_ui/test/test_data.py @@ -72,3 +72,15 @@ nameless_symmetric_key = objects.symmetric_key.SymmetricKey( name=None, created=1448088699, id=u'11111111-1111-1111-1111-111111111111') + +opaque_data = objects.opaque_data.OpaqueData( + data=b'\xde\xad\xbe\xef', + name=u'test opaque data', + created=1448088699, + id=u'00000000-0000-0000-0000-000000000000') + +nameless_opaque_data = objects.opaque_data.OpaqueData( + data=b'\xde\xad\xbe\xef', + name=None, + created=1448088699, + id=u'11111111-1111-1111-1111-111111111111')