From 2668b69479b2ec8be09b7172198b1d51655660fe Mon Sep 17 00:00:00 2001
From: Kaitlin Farr
Date: Thu, 9 Nov 2017 15:12:02 -0500
Subject: [PATCH] Add X509 Certificate Panel
Change-Id: Ibb459d3c7c8c5eb1e10dec09fb7130a26c8c426f
---
README.rst | 2 +-
castellan_ui/content/filters.py | 18 ++
.../content/x509_certificates/__init__.py | 0
.../content/x509_certificates/forms.py | 114 +++++++++++
.../content/x509_certificates/panel.py | 23 +++
.../content/x509_certificates/tables.py | 84 ++++++++
.../content/x509_certificates/urls.py | 26 +++
.../content/x509_certificates/views.py | 186 ++++++++++++++++++
...ect_key_manager_x509_certificates_panel.py | 23 +++
castellan_ui/templates/_object_import.html | 54 +++++
.../templates/_x509_certificate_import.html | 9 +
.../templates/x509_certificate_detail.html | 37 ++++
.../templates/x509_certificate_import.html | 7 +
castellan_ui/templates/x509_certificates.html | 23 +++
castellan_ui/test/content/__init__.py | 0
.../content/x509_certificates/__init__.py | 0
.../test/content/x509_certificates/tests.py | 108 ++++++++++
castellan_ui/test/test_data.py | 26 +++
tox.ini | 2 +-
19 files changed, 740 insertions(+), 2 deletions(-)
create mode 100644 castellan_ui/content/filters.py
create mode 100644 castellan_ui/content/x509_certificates/__init__.py
create mode 100644 castellan_ui/content/x509_certificates/forms.py
create mode 100644 castellan_ui/content/x509_certificates/panel.py
create mode 100644 castellan_ui/content/x509_certificates/tables.py
create mode 100644 castellan_ui/content/x509_certificates/urls.py
create mode 100644 castellan_ui/content/x509_certificates/views.py
create mode 100644 castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py
create mode 100644 castellan_ui/templates/_object_import.html
create mode 100644 castellan_ui/templates/_x509_certificate_import.html
create mode 100644 castellan_ui/templates/x509_certificate_detail.html
create mode 100644 castellan_ui/templates/x509_certificate_import.html
create mode 100644 castellan_ui/templates/x509_certificates.html
create mode 100644 castellan_ui/test/content/__init__.py
create mode 100644 castellan_ui/test/content/x509_certificates/__init__.py
create mode 100644 castellan_ui/test/content/x509_certificates/tests.py
create mode 100644 castellan_ui/test/test_data.py
diff --git a/README.rst b/README.rst
index 822b132..6899365 100644
--- a/README.rst
+++ b/README.rst
@@ -54,7 +54,7 @@ Install Castellan UI with all dependencies in your virtual environment::
And enable it in Horizon::
ln -s ../castellan-ui/castellan_ui/enabled/_90_project_key_manager_panelgroup.py openstack_dashboard/local/enabled
- TODO(kfarr): add the panels here
+ ln -s ../castellan-ui/castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py openstack_dashboard/local/enabled
To run horizon with the newly enabled Castellan UI plugin run::
diff --git a/castellan_ui/content/filters.py b/castellan_ui/content/filters.py
new file mode 100644
index 0000000..7d60bc9
--- /dev/null
+++ b/castellan_ui/content/filters.py
@@ -0,0 +1,18 @@
+# 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 datetime import datetime
+from horizon.utils import filters as horizon_filters
+
+
+def timestamp_to_iso(timestamp):
+ date = datetime.utcfromtimestamp(timestamp)
+ return horizon_filters.parse_isotime(date.isoformat())
diff --git a/castellan_ui/content/x509_certificates/__init__.py b/castellan_ui/content/x509_certificates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/castellan_ui/content/x509_certificates/forms.py b/castellan_ui/content/x509_certificates/forms.py
new file mode 100644
index 0000000..b0314df
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/forms.py
@@ -0,0 +1,114 @@
+# 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
+from django.utils.translation import ugettext_lazy as _
+import re
+
+from castellan.common.objects import x_509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.serialization import Encoding
+from cryptography.x509 import load_pem_x509_certificate
+from horizon import exceptions
+from horizon import forms
+from horizon import messages
+
+from castellan_ui.api import client
+
+NAME_REGEX = re.compile(r"^\w+(?:[- ]\w+)*$", re.UNICODE)
+ERROR_MESSAGES = {
+ 'invalid': _('Name may only contain letters, '
+ 'numbers, underscores, spaces, and hyphens '
+ 'and may not be white space.')}
+
+
+class ImportX509Certificate(forms.SelfHandlingForm):
+ name = forms.RegexField(required=False,
+ max_length=255,
+ label=_("Certificate Name"),
+ regex=NAME_REGEX,
+ error_messages=ERROR_MESSAGES)
+ source_type = forms.ChoiceField(
+ label=_('Source'),
+ required=False,
+ choices=[('file', _('Import File')),
+ ('raw', _('Direct Input'))],
+ widget=forms.ThemableSelectWidget(
+ attrs={'class': 'switchable', 'data-slug': 'source'}))
+ cert_file = forms.FileField(
+ label=_("Choose file"),
+ widget=forms.FileInput(
+ attrs={'class': 'switched', 'data-switch-on': 'source',
+ 'data-source-file': _('PEM Certificate File')}),
+ required=False)
+ direct_input = forms.CharField(
+ label=_('PEM Certificate'),
+ widget=forms.widgets.Textarea(
+ attrs={'class': 'switched', 'data-switch-on': 'source',
+ 'data-source-raw': _('PEM Certificate')}),
+ required=False)
+
+ def clean(self):
+ data = super(ImportX509Certificate, self).clean()
+
+ # The cert can be missing based on particular upload
+ # conditions. Code defensively for it here...
+ cert_file = data.get('cert_file', None)
+ cert_raw = data.get('direct_input', None)
+
+ if cert_raw and cert_file:
+ raise forms.ValidationError(
+ _("Cannot specify both file and direct input."))
+ if not cert_raw and not cert_file:
+ raise forms.ValidationError(
+ _("No input was provided for the certificate value."))
+ try:
+ if cert_file:
+ cert_pem = self.files['cert_file'].read()
+ else:
+ cert_pem = str(data['direct_input'])
+ cert_obj = load_pem_x509_certificate(
+ cert_pem.encode('utf-8'), default_backend())
+ cert_der = cert_obj.public_bytes(Encoding.DER)
+ except Exception as e:
+ msg = _('There was a problem loading the certificate: %s. '
+ 'Is the certificate valid and in PEM format?') % e
+ raise forms.ValidationError(msg)
+
+ data['cert_data'] = base64.b64encode(cert_der).decode('utf-8')
+
+ return data
+
+ def handle(self, request, data):
+ try:
+ cert_pem = data.get('cert_data')
+ cert_uuid = client.import_object(
+ request,
+ data=cert_pem,
+ name=data['name'],
+ object_type=x_509.X509)
+
+ if data['name']:
+ identifier = data['name']
+ else:
+ identifier = cert_uuid
+ messages.success(request,
+ _('Successfully imported certificate: %s')
+ % identifier)
+ return cert_uuid
+ except Exception as e:
+ msg = _('Unable to import certificate: %s')
+ messages.error(request, msg % e)
+ exceptions.handle(request, ignore=True)
+ self.api_error(_('Unable to import certificate.'))
+ return False
diff --git a/castellan_ui/content/x509_certificates/panel.py b/castellan_ui/content/x509_certificates/panel.py
new file mode 100644
index 0000000..d843fa5
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/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 X509Certificates(horizon.Panel):
+ name = _("X.509 Certificates")
+ slug = "x509_certificates"
diff --git a/castellan_ui/content/x509_certificates/tables.py b/castellan_ui/content/x509_certificates/tables.py
new file mode 100644
index 0000000..73457e4
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/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 ImportX509Certificate(tables.LinkAction):
+ name = "import_x509_certificate"
+ verbose_name = _("Import Certificate")
+ url = "horizon:project:x509_certificates:import"
+ classes = ("ajax-modal",)
+ icon = "upload"
+ policy_rules = ()
+
+
+class DownloadX509Certificate(tables.LinkAction):
+ name = "download"
+ verbose_name = _("Download Certificate")
+ url = "horizon:project:x509_certificates:download"
+ classes = ("btn-download",)
+ policy_rules = ()
+
+ def get_link_url(self, datum):
+ return reverse(self.url,
+ kwargs={'object_id': datum.id})
+
+
+class DeleteX509Certificate(tables.DeleteAction):
+ policy_rules = ()
+ help_text = _("You should not delete a certificate unless you are "
+ "certain it is not being used anywhere.")
+
+ @staticmethod
+ def action_present(count):
+ return ungettext_lazy(
+ u"Delete X.509 Certificate",
+ u"Delete X.509 Certificates",
+ count
+ )
+
+ @staticmethod
+ def action_past(count):
+ return ungettext_lazy(
+ u"Deleted X.509 Certificate",
+ u"Deleted X.509 Certificates",
+ count
+ )
+
+ def delete(self, request, obj_id):
+ client.delete(request, obj_id)
+
+
+class X509CertificateTable(tables.DataTable):
+ detail_link = "horizon:project:x509_certificates:detail"
+ uuid = tables.Column("id", verbose_name=_("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 = "x509_certificate"
+ table_actions = (ImportX509Certificate,
+ DeleteX509Certificate,)
+ row_actions = (DownloadX509Certificate, DeleteX509Certificate)
diff --git a/castellan_ui/content/x509_certificates/urls.py b/castellan_ui/content/x509_certificates/urls.py
new file mode 100644
index 0000000..011396c
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/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.x509_certificates 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_cert, name='download'),
+ url(r'^(?P[^/]+)/download$',
+ views.download_cert,
+ name='download'),
+]
diff --git a/castellan_ui/content/x509_certificates/views.py b/castellan_ui/content/x509_certificates/views.py
new file mode 100644
index 0000000..da37caa
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/views.py
@@ -0,0 +1,186 @@
+# 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 x_509
+from castellan_ui.api import client
+from castellan_ui.content.x509_certificates import forms as x509_forms
+from castellan_ui.content.x509_certificates import tables
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.serialization import Encoding
+from cryptography.x509 import load_der_x509_certificate
+
+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_cert(request, object_id):
+ try:
+ obj = client.get(request, object_id)
+ der_data = obj.get_encoded()
+ cert_obj = load_der_x509_certificate(der_data, default_backend())
+ data = cert_obj.public_bytes(Encoding.PEM)
+ response = HttpResponse()
+ response.write(data)
+ response['Content-Disposition'] = ('attachment; '
+ 'filename="%s.pem"' % object_id)
+ response['Content-Length'] = str(len(response.content))
+ return response
+
+ except Exception:
+ redirect = reverse('horizon:project:x509_certificates:index')
+ msg = _('Unable to download x509_certificate "%s".')\
+ % (object_id)
+ exceptions.handle(request, msg, redirect=redirect)
+
+
+class IndexView(tables_views.MultiTableView):
+ table_classes = [
+ tables.X509CertificateTable
+ ]
+ template_name = 'x509_certificates.html'
+
+ def get_x509_certificate_data(self):
+ try:
+ return client.list(self.request, object_type=x_509.X509)
+ except Exception as e:
+ msg = _('Unable to list certificates: "%s".') % (e.message)
+ exceptions.handle(self.request, msg)
+ return []
+
+
+class ImportView(forms.ModalFormView):
+ form_class = x509_forms.ImportX509Certificate
+ template_name = 'x509_certificate_import.html'
+ submit_url = reverse_lazy(
+ "horizon:project:x509_certificates:import")
+ success_url = reverse_lazy('horizon:project:x509_certificates:index')
+ submit_label = page_title = _("Import X.509 Certificate")
+
+ def get_form(self, form_class=None):
+ if form_class is None:
+ form_class = self.get_form_class()
+ return form_class(self.request, **self.get_form_kwargs())
+
+ def get_object_id(self, key_uuid):
+ return key_uuid
+
+
+class DetailView(views.HorizonTemplateView):
+ template_name = 'x509_certificate_detail.html'
+ page_title = _("X.509 Certificate Details")
+
+ @memoized.memoized_method
+ def _get_data(self):
+ try:
+ obj = client.get(self.request, self.kwargs['object_id'])
+ except Exception:
+ redirect = reverse('horizon:project:x509_certificates:index')
+ msg = _('Unable to retrieve details for x509_certificate "%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:x509_certificates:index')
+ msg = _('Unable to retrieve details for x509_certificate "%s".')\
+ % (self.kwargs['object_id'])
+ exceptions.handle(self.request, msg,
+ redirect=redirect)
+ return created_date
+
+ @memoized.memoized_method
+ def _get_crypto_obj(self, obj):
+ der_data = obj.get_encoded()
+ return load_der_x509_certificate(der_data, default_backend())
+
+ @memoized.memoized_method
+ def _get_certificate_version(self, obj):
+ return self._get_crypto_obj(obj).version
+
+ @memoized.memoized_method
+ def _get_certificate_fingerprint(self, obj):
+ return binascii.hexlify(
+ self._get_crypto_obj(obj).fingerprint(hashes.SHA256()))
+
+ @memoized.memoized_method
+ def _get_serial_number(self, obj):
+ return self._get_crypto_obj(obj).serial_number
+
+ @memoized.memoized_method
+ def _get_validity_start(self, obj):
+ return self._get_crypto_obj(obj).not_valid_before
+
+ @memoized.memoized_method
+ def _get_validity_end(self, obj):
+ return self._get_crypto_obj(obj).not_valid_after
+
+ @memoized.memoized_method
+ def _get_issuer(self, obj):
+ result = ""
+ issuer = self._get_crypto_obj(obj).issuer
+ for attribute in issuer:
+ result = (result + str(attribute.oid._name) + "=" +
+ str(attribute.value) + ",")
+ return result[:-1]
+
+ @memoized.memoized_method
+ def _get_subject(self, obj):
+ result = ""
+ issuer = self._get_crypto_obj(obj).subject
+ for attribute in issuer:
+ result = (result + str(attribute.oid._name) + "=" +
+ str(attribute.value) + ",")
+ return result[:-1]
+
+ @memoized.memoized_method
+ def _get_data_bytes(self, obj):
+ try:
+ data = self._get_crypto_obj(obj).public_bytes(Encoding.PEM)
+ except Exception:
+ redirect = reverse('horizon:project:x509_certificates:index')
+ msg = _('Unable to retrieve details for x509_certificate "%s".')\
+ % (self.kwargs['object_id'])
+ exceptions.handle(self.request, msg,
+ redirect=redirect)
+ return data
+
+ 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)
+ context['cert_version'] = self._get_certificate_version(obj)
+ context['cert_fingerprint'] = self._get_certificate_fingerprint(obj)
+ context['cert_serial_number'] = self._get_serial_number(obj)
+ context['cert_validity_start'] = self._get_validity_start(obj)
+ context['cert_validity_end'] = self._get_validity_end(obj)
+ context['cert_issuer'] = self._get_issuer(obj)
+ context['cert_subject'] = self._get_subject(obj)
+ return context
diff --git a/castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py b/castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py
new file mode 100644
index 0000000..c04e152
--- /dev/null
+++ b/castellan_ui/enabled/_91_project_key_manager_x509_certificates_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 = 'x509_certificates'
+# 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.x509_certificates.panel.X509Certificates'
diff --git a/castellan_ui/templates/_object_import.html b/castellan_ui/templates/_object_import.html
new file mode 100644
index 0000000..6e3b432
--- /dev/null
+++ b/castellan_ui/templates/_object_import.html
@@ -0,0 +1,54 @@
+{% extends "horizon/common/_modal.html" %}
+{% block content %}
+ {% if table %}
+
+ {{ table.render }}
+
+ {% endif %}
+
+{% endblock %}
+{% load i18n %}
+
diff --git a/castellan_ui/templates/_x509_certificate_import.html b/castellan_ui/templates/_x509_certificate_import.html
new file mode 100644
index 0000000..3830057
--- /dev/null
+++ b/castellan_ui/templates/_x509_certificate_import.html
@@ -0,0 +1,9 @@
+{% extends '_object_import.html' %}
+{% load i18n %}
+
+{% block modal-body-right %}
+ {% trans "X.509 certificates can be imported if they are in Privacy Enhanced Mail (PEM) format." %}
+ {% trans "Your PEM formatted certificate will look like this:" %}
+ -----BEGIN CERTIFICATE-----
<base64-encoded data>
-----END CERTIFICATE-----
+{% endblock %}
+
diff --git a/castellan_ui/templates/x509_certificate_detail.html b/castellan_ui/templates/x509_certificate_detail.html
new file mode 100644
index 0000000..eb3eb8c
--- /dev/null
+++ b/castellan_ui/templates/x509_certificate_detail.html
@@ -0,0 +1,37 @@
+{% 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 "Certificate Version" %}
+ - {{ cert_version}}
+ - {% trans "SHA-256 Fingerprint" %}
+ - {{ cert_fingerprint}}
+ - {% trans "Serial Number" %}
+ - {{ cert_serial_number}}
+ - {% trans "Valid From" %}
+ - {{ cert_validity_start }}
+ - {% trans "Valid To" %}
+ - {{ cert_validity_end }}
+ - {% trans "Issuer" %}
+ - {{ cert_issuer}}
+ - {% trans "Subject" %}
+ - {{ cert_subject}}
+ - {% trans "Raw Certificate Value" %}
+ -
+
{{ object_bytes|default:_("None") }}
+
+
+
+{% endblock %}
diff --git a/castellan_ui/templates/x509_certificate_import.html b/castellan_ui/templates/x509_certificate_import.html
new file mode 100644
index 0000000..1ac1bba
--- /dev/null
+++ b/castellan_ui/templates/x509_certificate_import.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{{ page_title }}{% endblock %}
+
+{% block main %}
+ {% include '_x509_certificate_import.html' %}
+{% endblock %}
diff --git a/castellan_ui/templates/x509_certificates.html b/castellan_ui/templates/x509_certificates.html
new file mode 100644
index 0000000..4b66c8c
--- /dev/null
+++ b/castellan_ui/templates/x509_certificates.html
@@ -0,0 +1,23 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "X.509 Certificates" %}{% endblock %}
+
+{% block breadcrumb_nav %}
+
+ - {% trans "Project" %}
+ - {% trans "Key Manager" %}
+ - {% trans "X.509 Certificates" %}
+
+{% endblock %}
+
+{% block page_header %}
+
+{% endblock page_header %}
+
+{% block main %}
+
+
+ {{ x509_certificate_table.render }}
+
+
+{% endblock %}
diff --git a/castellan_ui/test/content/__init__.py b/castellan_ui/test/content/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/castellan_ui/test/content/x509_certificates/__init__.py b/castellan_ui/test/content/x509_certificates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/castellan_ui/test/content/x509_certificates/tests.py b/castellan_ui/test/content/x509_certificates/tests.py
new file mode 100644
index 0000000..c6abf26
--- /dev/null
+++ b/castellan_ui/test/content/x509_certificates/tests.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
+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 x_509
+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:x509_certificates:index')
+
+
+class X509CertificatesViewTest(tests.APITestCase):
+
+ def setUp(self):
+ super(X509CertificatesViewTest, self).setUp()
+ self.cert = test_data.x509_cert
+ self.cert_b64_bytes = base64.b64encode(self.cert.get_encoded())
+ self.mock_object(
+ api_castellan, "get", mock.Mock(return_value=self.cert))
+ 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):
+ cert_list = [test_data.x509_cert, test_data.nameless_x509_cert]
+
+ self.mock_object(
+ api_castellan, "list", mock.Mock(return_value=cert_list))
+
+ res = self.client.get(INDEX_URL)
+ self.assertEqual(res.status_code, 200)
+ self.assertTemplateUsed(res, 'x509_certificates.html')
+ api_castellan.list.assert_called_with(mock.ANY, object_type=x_509.X509)
+
+ def test_detail_view(self):
+ url = reverse('horizon:project:x509_certificates:detail',
+ args=[self.cert.id])
+ self.mock_object(
+ api_castellan, "list", mock.Mock(return_value=[self.cert]))
+ self.mock_object(
+ api_castellan, "get", mock.Mock(return_value=self.cert))
+
+ res = self.client.get(url)
+ self.assertContains(
+ res, "Name\n %s" % self.cert.name, 1, 200)
+ api_castellan.get.assert_called_once_with(mock.ANY, self.cert.id)
+
+ def test_import_cert(self):
+ self.mock_object(
+ api_castellan, "list", mock.Mock(return_value=[self.cert]))
+ url = reverse('horizon:project:x509_certificates:import')
+ self.mock_object(
+ api_castellan, "import_object", mock.Mock(return_value=self.cert))
+
+ cert_input = (
+ u"-----BEGIN CERTIFICATE-----\n" +
+ self.cert_b64_bytes.decode("utf-8") +
+ u"\n-----END CERTIFICATE-----"
+ )
+
+ cert_form_data = {
+ 'source_type': 'raw',
+ 'name': self.cert.name,
+ 'direct_input': cert_input
+ }
+
+ self.client.post(url, cert_form_data)
+
+ api_castellan.import_object.assert_called_once_with(
+ mock.ANY,
+ object_type=x_509.X509,
+ data=self.cert_b64_bytes.decode('utf-8'),
+ name=self.cert.name
+ )
+
+ def test_delete_cert(self):
+ self.mock_object(
+ api_castellan, "list", mock.Mock(return_value=[self.cert]))
+ self.mock_object(api_castellan, "delete")
+
+ cert_form_data = {
+ 'action': 'x509_certificate__delete__%s' % self.cert.id
+ }
+
+ res = self.client.post(INDEX_URL, cert_form_data)
+
+ api_castellan.list.assert_called_with(mock.ANY, object_type=x_509.X509)
+ api_castellan.delete.assert_called_once_with(
+ mock.ANY,
+ self.cert.id,
+ )
+ self.assertRedirectsNoFollow(res, INDEX_URL)
diff --git a/castellan_ui/test/test_data.py b/castellan_ui/test/test_data.py
new file mode 100644
index 0000000..33c1b8a
--- /dev/null
+++ b/castellan_ui/test/test_data.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.common import objects
+from castellan.tests import utils as castellan_utils
+
+x509_cert = objects.x_509.X509(
+ data=castellan_utils.get_certificate_der(),
+ name='test cert',
+ created=1448088699,
+ id=u'00000000-0000-0000-0000-000000000000')
+
+nameless_x509_cert = objects.x_509.X509(
+ data=castellan_utils.get_certificate_der(),
+ name=None,
+ created=1448088699,
+ id=u'11111111-1111-1111-1111-111111111111')
diff --git a/tox.ini b/tox.ini
index 4a6497f..a01f5c3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -65,4 +65,4 @@ commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasen
[flake8]
exclude = .venv,.git,.tox,dist,*lib/python*,*egg,build,node_modules,.tmp
-max-complexity = 20
\ No newline at end of file
+max-complexity = 20