From bbcebe522f1366791aca78475770515c3eed4d39 Mon Sep 17 00:00:00 2001 From: Kaitlin Farr Date: Mon, 13 Nov 2017 15:14:33 -0500 Subject: [PATCH] Add Passphrase Panel Change-Id: If52f1c29877d91cce50fdfb61319bd195103e6e1 --- README.rst | 1 + castellan_ui/content/passphrases/__init__.py | 0 castellan_ui/content/passphrases/forms.py | 61 ++++++++++ castellan_ui/content/passphrases/panel.py | 23 ++++ castellan_ui/content/passphrases/tables.py | 72 +++++++++++ castellan_ui/content/passphrases/urls.py | 22 ++++ castellan_ui/content/passphrases/views.py | 96 +++++++++++++++ ...6_project_key_manager_passphrases_panel.py | 23 ++++ .../templates/_passphrase_import.html | 7 ++ castellan_ui/templates/passphrase_detail.html | 49 ++++++++ castellan_ui/templates/passphrase_import.html | 7 ++ castellan_ui/templates/passphrases.html | 23 ++++ .../test/content/passphrases/__init__.py | 0 .../test/content/passphrases/tests.py | 112 ++++++++++++++++++ castellan_ui/test/test_data.py | 12 ++ 15 files changed, 508 insertions(+) create mode 100644 castellan_ui/content/passphrases/__init__.py create mode 100644 castellan_ui/content/passphrases/forms.py create mode 100644 castellan_ui/content/passphrases/panel.py create mode 100644 castellan_ui/content/passphrases/tables.py create mode 100644 castellan_ui/content/passphrases/urls.py create mode 100644 castellan_ui/content/passphrases/views.py create mode 100644 castellan_ui/enabled/_96_project_key_manager_passphrases_panel.py create mode 100644 castellan_ui/templates/_passphrase_import.html create mode 100644 castellan_ui/templates/passphrase_detail.html create mode 100644 castellan_ui/templates/passphrase_import.html create mode 100644 castellan_ui/templates/passphrases.html create mode 100644 castellan_ui/test/content/passphrases/__init__.py create mode 100644 castellan_ui/test/content/passphrases/tests.py diff --git a/README.rst b/README.rst index bf5bc16..8400e1b 100644 --- a/README.rst +++ b/README.rst @@ -59,6 +59,7 @@ And enable it in Horizon:: 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/_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 + ln -s ../castellan-ui/castellan_ui/enabled/_96_project_key_manager_passphrase_panel.py openstack_dashboard/local/enabled To run horizon with the newly enabled Castellan UI plugin run:: diff --git a/castellan_ui/content/passphrases/__init__.py b/castellan_ui/content/passphrases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/castellan_ui/content/passphrases/forms.py b/castellan_ui/content/passphrases/forms.py new file mode 100644 index 0000000..ef786b9 --- /dev/null +++ b/castellan_ui/content/passphrases/forms.py @@ -0,0 +1,61 @@ +# 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 _ + +from castellan.common.objects import passphrase +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 ImportPassphrase(forms.SelfHandlingForm): + name = forms.RegexField(required=False, + max_length=255, + label=_("Passphrase Name"), + regex=shared_forms.NAME_REGEX, + error_messages=shared_forms.ERROR_MESSAGES) + direct_input = forms.CharField( + label=_('Passphrase'), + help_text=_('The text of the passphrase in plaintext'), + widget=forms.widgets.Textarea(), + required=True) + + def handle(self, request, data): + try: + # Remove any new lines in the passphrase + direct_input = data.get('direct_input') + direct_input = shared_forms.NEW_LINES.sub("", direct_input) + object_uuid = client.import_object( + request, + passphrase=direct_input, + name=data['name'], + object_type=passphrase.Passphrase) + + if data['name']: + object_identifier = data['name'] + else: + object_identifier = object_uuid + messages.success(request, + _('Successfully imported passphrase: %s') + % object_identifier) + return object_uuid + except Exception as e: + msg = _('Unable to import passphrase: %s') + messages.error(request, msg % e) + exceptions.handle(request, ignore=True) + self.api_error(_('Unable to import passphrase.')) + return False diff --git a/castellan_ui/content/passphrases/panel.py b/castellan_ui/content/passphrases/panel.py new file mode 100644 index 0000000..320d2f2 --- /dev/null +++ b/castellan_ui/content/passphrases/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 Passphrases(horizon.Panel): + name = _("Passphrases") + slug = "passphrases" diff --git a/castellan_ui/content/passphrases/tables.py b/castellan_ui/content/passphrases/tables.py new file mode 100644 index 0000000..4f77928 --- /dev/null +++ b/castellan_ui/content/passphrases/tables.py @@ -0,0 +1,72 @@ +# 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.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from castellan_ui.api import client +from horizon import tables + + +class ImportPassphrase(tables.LinkAction): + name = "import_passphrase" + verbose_name = _("Import Passphrase") + url = "horizon:project:passphrases:import" + classes = ("ajax-modal",) + icon = "upload" + policy_rules = () + + +class DeletePassphrase(tables.DeleteAction): + policy_rules = () + help_text = _("You should not delete a passphrase unless you are " + "certain it is not being used anywhere.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Passphrase", + u"Delete Passphrases", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Deleted Passphrase", + u"Deleted Passphrases", + count + ) + + def delete(self, request, obj_id): + client.delete(request, obj_id) + + +class PassphraseTable(tables.DataTable): + detail_link = "horizon:project:passphrases:detail" + uuid = tables.Column("id", verbose_name=_("Passphrase 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 = "passphrase" + table_actions = (ImportPassphrase, + DeletePassphrase,) + row_actions = (DeletePassphrase, ) diff --git a/castellan_ui/content/passphrases/urls.py b/castellan_ui/content/passphrases/urls.py new file mode 100644 index 0000000..713c986 --- /dev/null +++ b/castellan_ui/content/passphrases/urls.py @@ -0,0 +1,22 @@ +# 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.passphrases 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'), +] diff --git a/castellan_ui/content/passphrases/views.py b/castellan_ui/content/passphrases/views.py new file mode 100644 index 0000000..6822871 --- /dev/null +++ b/castellan_ui/content/passphrases/views.py @@ -0,0 +1,96 @@ +# 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.utils.translation import ugettext_lazy as _ + +from castellan.common.objects import passphrase +from castellan_ui.api import client +from castellan_ui.content.passphrases import forms as passphrase_forms +from castellan_ui.content.passphrases 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 + + +class IndexView(tables_views.MultiTableView): + table_classes = [ + tables.PassphraseTable + ] + template_name = 'passphrases.html' + + def get_passphrase_data(self): + try: + return client.list( + self.request, object_type=passphrase.Passphrase) + except Exception as e: + msg = _('Unable to list passphrases: "%s".') % (e.message) + exceptions.handle(self.request, msg) + return [] + + +class ImportView(forms.ModalFormView): + form_class = passphrase_forms.ImportPassphrase + template_name = 'passphrase_import.html' + submit_url = reverse_lazy( + "horizon:project:passphrases:import") + success_url = reverse_lazy('horizon:project:passphrases:index') + submit_label = page_title = _("Import Passphrase") + + def get_object_id(self, key_uuid): + return key_uuid + + +class DetailView(views.HorizonTemplateView): + template_name = 'passphrase_detail.html' + page_title = _("Passphrase Details") + + @memoized.memoized_method + def _get_data(self): + try: + obj = client.get(self.request, self.kwargs['object_id']) + except Exception: + redirect = reverse('horizon:project:passphrases:index') + msg = _('Unable to retrieve details for passphrase "%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:passphrases:index') + msg = _('Unable to retrieve details for passphrase "%s".')\ + % (self.kwargs['object_id']) + exceptions.handle(self.request, msg, + redirect=redirect) + return created_date + + @memoized.memoized_method + def _get_data_bytes(self, obj): + return obj.get_encoded() + + 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/_96_project_key_manager_passphrases_panel.py b/castellan_ui/enabled/_96_project_key_manager_passphrases_panel.py new file mode 100644 index 0000000..7f70aaa --- /dev/null +++ b/castellan_ui/enabled/_96_project_key_manager_passphrases_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 = 'passphrases' +# 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.passphrases.panel.Passphrases' diff --git a/castellan_ui/templates/_passphrase_import.html b/castellan_ui/templates/_passphrase_import.html new file mode 100644 index 0000000..a213022 --- /dev/null +++ b/castellan_ui/templates/_passphrase_import.html @@ -0,0 +1,7 @@ +{% extends '_object_import.html' %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Enter the passphrase as you would type it on on the command line or into a form." %}

+{% endblock %} + diff --git a/castellan_ui/templates/passphrase_detail.html b/castellan_ui/templates/passphrase_detail.html new file mode 100644 index 0000000..af55269 --- /dev/null +++ b/castellan_ui/templates/passphrase_detail.html @@ -0,0 +1,49 @@ + + + + +{% 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 "Passphrase" %}
+
+ + +
+
+
+{% endblock %} diff --git a/castellan_ui/templates/passphrase_import.html b/castellan_ui/templates/passphrase_import.html new file mode 100644 index 0000000..864272c --- /dev/null +++ b/castellan_ui/templates/passphrase_import.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include '_passphrase_import.html' %} +{% endblock %} diff --git a/castellan_ui/templates/passphrases.html b/castellan_ui/templates/passphrases.html new file mode 100644 index 0000000..232a270 --- /dev/null +++ b/castellan_ui/templates/passphrases.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Passphrases" %}{% endblock %} + +{% block breadcrumb_nav %} + +{% endblock %} + +{% block page_header %} + +{% endblock page_header %} + +{% block main %} +
+
+ {{ passphrase_table.render }} +
+
+{% endblock %} diff --git a/castellan_ui/test/content/passphrases/__init__.py b/castellan_ui/test/content/passphrases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/castellan_ui/test/content/passphrases/tests.py b/castellan_ui/test/content/passphrases/tests.py new file mode 100644 index 0000000..09c2e36 --- /dev/null +++ b/castellan_ui/test/content/passphrases/tests.py @@ -0,0 +1,112 @@ +# 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.handlers import wsgi +from django.core.urlresolvers import reverse +from horizon import messages as horizon_messages +import mock + +from castellan.common.objects import passphrase +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:passphrases:index') + + +class PassphrasesViewTest(tests.APITestCase): + + class FakeCert(object): + def __init__(self): + pass + + def setUp(self): + super(PassphrasesViewTest, self).setUp() + self.passphrase = test_data.passphrase + self.mock_object( + api_castellan, "get", mock.Mock(return_value=self.passphrase)) + 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): + passphrase_list = [test_data.passphrase, test_data.nameless_passphrase] + + self.mock_object( + api_castellan, "list", mock.Mock(return_value=passphrase_list)) + + res = self.client.get(INDEX_URL) + self.assertEqual(res.status_code, 200) + self.assertTemplateUsed(res, 'passphrases.html') + api_castellan.list.assert_called_with( + mock.ANY, object_type=passphrase.Passphrase) + + def test_detail_view(self): + url = reverse('horizon:project:passphrases:detail', + args=[self.passphrase.id]) + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.passphrase])) + self.mock_object( + api_castellan, "get", mock.Mock(return_value=self.passphrase)) + + res = self.client.get(url) + self.assertContains( + res, "
Name
\n
%s
" % self.passphrase.name, + 1, 200) + api_castellan.get.assert_called_once_with(mock.ANY, self.passphrase.id) + + def test_import_cert(self): + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.passphrase])) + url = reverse('horizon:project:passphrases:import') + self.mock_object( + api_castellan, "import_object", mock.Mock( + return_value=self.passphrase)) + + passphrase_input = ( + self.passphrase.get_encoded() + ) + + passphrase_form_data = { + 'source_type': 'raw', + 'name': self.passphrase.name, + 'direct_input': passphrase_input + } + + self.client.post(url, passphrase_form_data) + + api_castellan.import_object.assert_called_once_with( + mock.ANY, + object_type=passphrase.Passphrase, + passphrase=self.passphrase.get_encoded(), + name=self.passphrase.name + ) + + def test_delete_cert(self): + self.mock_object( + api_castellan, "list", mock.Mock(return_value=[self.passphrase])) + self.mock_object(api_castellan, "delete") + + passphrase_form_data = { + 'action': 'passphrase__delete__%s' % self.passphrase.id + } + + res = self.client.post(INDEX_URL, passphrase_form_data) + + api_castellan.list.assert_called_with( + mock.ANY, object_type=passphrase.Passphrase) + api_castellan.delete.assert_called_once_with( + mock.ANY, + self.passphrase.id, + ) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/castellan_ui/test/test_data.py b/castellan_ui/test/test_data.py index 675d5a4..49f46ee 100644 --- a/castellan_ui/test/test_data.py +++ b/castellan_ui/test/test_data.py @@ -84,3 +84,15 @@ nameless_opaque_data = objects.opaque_data.OpaqueData( name=None, created=1448088699, id=u'11111111-1111-1111-1111-111111111111') + +passphrase = objects.passphrase.Passphrase( + passphrase=u'P@ssw0rd', + name=u'test passphrase', + created=1448088699, + id=u'00000000-0000-0000-0000-000000000000') + +nameless_passphrase = objects.passphrase.Passphrase( + passphrase=u'P@ssw0rd', + name=None, + created=1448088699, + id=u'11111111-1111-1111-1111-111111111111')