summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaitlin Farr <kaitlin.farr@jhuapl.edu>2017-11-09 15:12:02 -0500
committerKaitlin Farr <kaitlin.farr@jhuapl.edu>2018-02-27 15:33:19 -0500
commit2668b69479b2ec8be09b7172198b1d51655660fe (patch)
tree9e1253077cc5a21b1fce5aaabd817023b7fec37e
parenta3efa18c9992d902616e751398036d05883081da (diff)
Add X509 Certificate Panel
Notes
Notes (review): Code-Review+2: Brianna Poulos <Brianna.Poulos@jhuapl.edu> Workflow+1: Brianna Poulos <Brianna.Poulos@jhuapl.edu> Verified+2: Zuul Submitted-by: Zuul Submitted-at: Tue, 27 Feb 2018 21:17:40 +0000 Reviewed-on: https://review.openstack.org/518313 Project: openstack/castellan-ui Branch: refs/heads/master
-rw-r--r--README.rst2
-rw-r--r--castellan_ui/content/filters.py18
-rw-r--r--castellan_ui/content/x509_certificates/__init__.py0
-rw-r--r--castellan_ui/content/x509_certificates/forms.py114
-rw-r--r--castellan_ui/content/x509_certificates/panel.py23
-rw-r--r--castellan_ui/content/x509_certificates/tables.py84
-rw-r--r--castellan_ui/content/x509_certificates/urls.py26
-rw-r--r--castellan_ui/content/x509_certificates/views.py186
-rw-r--r--castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py23
-rw-r--r--castellan_ui/templates/_object_import.html54
-rw-r--r--castellan_ui/templates/_x509_certificate_import.html9
-rw-r--r--castellan_ui/templates/x509_certificate_detail.html37
-rw-r--r--castellan_ui/templates/x509_certificate_import.html7
-rw-r--r--castellan_ui/templates/x509_certificates.html23
-rw-r--r--castellan_ui/test/content/__init__.py0
-rw-r--r--castellan_ui/test/content/x509_certificates/__init__.py0
-rw-r--r--castellan_ui/test/content/x509_certificates/tests.py108
-rw-r--r--castellan_ui/test/test_data.py26
-rw-r--r--tox.ini2
19 files changed, 740 insertions, 2 deletions
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::
54And enable it in Horizon:: 54And enable it in Horizon::
55 55
56 ln -s ../castellan-ui/castellan_ui/enabled/_90_project_key_manager_panelgroup.py openstack_dashboard/local/enabled 56 ln -s ../castellan-ui/castellan_ui/enabled/_90_project_key_manager_panelgroup.py openstack_dashboard/local/enabled
57 TODO(kfarr): add the panels here 57 ln -s ../castellan-ui/castellan_ui/enabled/_91_project_key_manager_x509_certificates_panel.py openstack_dashboard/local/enabled
58 58
59To run horizon with the newly enabled Castellan UI plugin run:: 59To run horizon with the newly enabled Castellan UI plugin run::
60 60
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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12from datetime import datetime
13from horizon.utils import filters as horizon_filters
14
15
16def timestamp_to_iso(timestamp):
17 date = datetime.utcfromtimestamp(timestamp)
18 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
--- /dev/null
+++ b/castellan_ui/content/x509_certificates/__init__.py
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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13
14import base64
15from django.utils.translation import ugettext_lazy as _
16import re
17
18from castellan.common.objects import x_509
19from cryptography.hazmat.backends import default_backend
20from cryptography.hazmat.primitives.serialization import Encoding
21from cryptography.x509 import load_pem_x509_certificate
22from horizon import exceptions
23from horizon import forms
24from horizon import messages
25
26from castellan_ui.api import client
27
28NAME_REGEX = re.compile(r"^\w+(?:[- ]\w+)*$", re.UNICODE)
29ERROR_MESSAGES = {
30 'invalid': _('Name may only contain letters, '
31 'numbers, underscores, spaces, and hyphens '
32 'and may not be white space.')}
33
34
35class ImportX509Certificate(forms.SelfHandlingForm):
36 name = forms.RegexField(required=False,
37 max_length=255,
38 label=_("Certificate Name"),
39 regex=NAME_REGEX,
40 error_messages=ERROR_MESSAGES)
41 source_type = forms.ChoiceField(
42 label=_('Source'),
43 required=False,
44 choices=[('file', _('Import File')),
45 ('raw', _('Direct Input'))],
46 widget=forms.ThemableSelectWidget(
47 attrs={'class': 'switchable', 'data-slug': 'source'}))
48 cert_file = forms.FileField(
49 label=_("Choose file"),
50 widget=forms.FileInput(
51 attrs={'class': 'switched', 'data-switch-on': 'source',
52 'data-source-file': _('PEM Certificate File')}),
53 required=False)
54 direct_input = forms.CharField(
55 label=_('PEM Certificate'),
56 widget=forms.widgets.Textarea(
57 attrs={'class': 'switched', 'data-switch-on': 'source',
58 'data-source-raw': _('PEM Certificate')}),
59 required=False)
60
61 def clean(self):
62 data = super(ImportX509Certificate, self).clean()
63
64 # The cert can be missing based on particular upload
65 # conditions. Code defensively for it here...
66 cert_file = data.get('cert_file', None)
67 cert_raw = data.get('direct_input', None)
68
69 if cert_raw and cert_file:
70 raise forms.ValidationError(
71 _("Cannot specify both file and direct input."))
72 if not cert_raw and not cert_file:
73 raise forms.ValidationError(
74 _("No input was provided for the certificate value."))
75 try:
76 if cert_file:
77 cert_pem = self.files['cert_file'].read()
78 else:
79 cert_pem = str(data['direct_input'])
80 cert_obj = load_pem_x509_certificate(
81 cert_pem.encode('utf-8'), default_backend())
82 cert_der = cert_obj.public_bytes(Encoding.DER)
83 except Exception as e:
84 msg = _('There was a problem loading the certificate: %s. '
85 'Is the certificate valid and in PEM format?') % e
86 raise forms.ValidationError(msg)
87
88 data['cert_data'] = base64.b64encode(cert_der).decode('utf-8')
89
90 return data
91
92 def handle(self, request, data):
93 try:
94 cert_pem = data.get('cert_data')
95 cert_uuid = client.import_object(
96 request,
97 data=cert_pem,
98 name=data['name'],
99 object_type=x_509.X509)
100
101 if data['name']:
102 identifier = data['name']
103 else:
104 identifier = cert_uuid
105 messages.success(request,
106 _('Successfully imported certificate: %s')
107 % identifier)
108 return cert_uuid
109 except Exception as e:
110 msg = _('Unable to import certificate: %s')
111 messages.error(request, msg % e)
112 exceptions.handle(request, ignore=True)
113 self.api_error(_('Unable to import certificate.'))
114 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13from django.utils.translation import ugettext_lazy as _
14import horizon
15
16# This panel will be loaded from horizon, because specified in enabled file.
17# To register REST api, import below here.
18from castellan_ui.api import client # noqa: F401
19
20
21class X509Certificates(horizon.Panel):
22 name = _("X.509 Certificates")
23 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13
14from castellan_ui.content import filters
15from django.core.urlresolvers import reverse
16from django.utils.translation import ugettext_lazy as _
17from django.utils.translation import ungettext_lazy
18
19from castellan_ui.api import client
20from horizon import tables
21
22
23class ImportX509Certificate(tables.LinkAction):
24 name = "import_x509_certificate"
25 verbose_name = _("Import Certificate")
26 url = "horizon:project:x509_certificates:import"
27 classes = ("ajax-modal",)
28 icon = "upload"
29 policy_rules = ()
30
31
32class DownloadX509Certificate(tables.LinkAction):
33 name = "download"
34 verbose_name = _("Download Certificate")
35 url = "horizon:project:x509_certificates:download"
36 classes = ("btn-download",)
37 policy_rules = ()
38
39 def get_link_url(self, datum):
40 return reverse(self.url,
41 kwargs={'object_id': datum.id})
42
43
44class DeleteX509Certificate(tables.DeleteAction):
45 policy_rules = ()
46 help_text = _("You should not delete a certificate unless you are "
47 "certain it is not being used anywhere.")
48
49 @staticmethod
50 def action_present(count):
51 return ungettext_lazy(
52 u"Delete X.509 Certificate",
53 u"Delete X.509 Certificates",
54 count
55 )
56
57 @staticmethod
58 def action_past(count):
59 return ungettext_lazy(
60 u"Deleted X.509 Certificate",
61 u"Deleted X.509 Certificates",
62 count
63 )
64
65 def delete(self, request, obj_id):
66 client.delete(request, obj_id)
67
68
69class X509CertificateTable(tables.DataTable):
70 detail_link = "horizon:project:x509_certificates:detail"
71 uuid = tables.Column("id", verbose_name=_("ID"), link=detail_link)
72 name = tables.Column("name", verbose_name=_("Name"))
73 created_date = tables.Column("created",
74 verbose_name=_("Created Date"),
75 filters=(filters.timestamp_to_iso,))
76
77 def get_object_display(self, datum):
78 return datum.name if datum.name else datum.id
79
80 class Meta(object):
81 name = "x509_certificate"
82 table_actions = (ImportX509Certificate,
83 DeleteX509Certificate,)
84 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13from castellan_ui.content.x509_certificates import views
14from django.conf.urls import url
15
16urlpatterns = [
17 url(r'^$', views.IndexView.as_view(), name='index'),
18 url(r'^import/$', views.ImportView.as_view(), name='import'),
19 url(r'^(?P<object_id>[^/]+)/$',
20 views.DetailView.as_view(),
21 name='detail'),
22 url(r'^download/$', views.download_cert, name='download'),
23 url(r'^(?P<object_id>[^/]+)/download$',
24 views.download_cert,
25 name='download'),
26]
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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13from django.core.urlresolvers import reverse
14from django.core.urlresolvers import reverse_lazy
15from django.http import HttpResponse
16from django.utils.translation import ugettext_lazy as _
17
18import binascii
19from castellan.common.objects import x_509
20from castellan_ui.api import client
21from castellan_ui.content.x509_certificates import forms as x509_forms
22from castellan_ui.content.x509_certificates import tables
23from cryptography.hazmat.backends import default_backend
24from cryptography.hazmat.primitives import hashes
25from cryptography.hazmat.primitives.serialization import Encoding
26from cryptography.x509 import load_der_x509_certificate
27
28from datetime import datetime
29from horizon import exceptions
30from horizon import forms
31from horizon.tables import views as tables_views
32from horizon.utils import memoized
33from horizon import views
34
35
36def download_cert(request, object_id):
37 try:
38 obj = client.get(request, object_id)
39 der_data = obj.get_encoded()
40 cert_obj = load_der_x509_certificate(der_data, default_backend())
41 data = cert_obj.public_bytes(Encoding.PEM)
42 response = HttpResponse()
43 response.write(data)
44 response['Content-Disposition'] = ('attachment; '
45 'filename="%s.pem"' % object_id)
46 response['Content-Length'] = str(len(response.content))
47 return response
48
49 except Exception:
50 redirect = reverse('horizon:project:x509_certificates:index')
51 msg = _('Unable to download x509_certificate "%s".')\
52 % (object_id)
53 exceptions.handle(request, msg, redirect=redirect)
54
55
56class IndexView(tables_views.MultiTableView):
57 table_classes = [
58 tables.X509CertificateTable
59 ]
60 template_name = 'x509_certificates.html'
61
62 def get_x509_certificate_data(self):
63 try:
64 return client.list(self.request, object_type=x_509.X509)
65 except Exception as e:
66 msg = _('Unable to list certificates: "%s".') % (e.message)
67 exceptions.handle(self.request, msg)
68 return []
69
70
71class ImportView(forms.ModalFormView):
72 form_class = x509_forms.ImportX509Certificate
73 template_name = 'x509_certificate_import.html'
74 submit_url = reverse_lazy(
75 "horizon:project:x509_certificates:import")
76 success_url = reverse_lazy('horizon:project:x509_certificates:index')
77 submit_label = page_title = _("Import X.509 Certificate")
78
79 def get_form(self, form_class=None):
80 if form_class is None:
81 form_class = self.get_form_class()
82 return form_class(self.request, **self.get_form_kwargs())
83
84 def get_object_id(self, key_uuid):
85 return key_uuid
86
87
88class DetailView(views.HorizonTemplateView):
89 template_name = 'x509_certificate_detail.html'
90 page_title = _("X.509 Certificate Details")
91
92 @memoized.memoized_method
93 def _get_data(self):
94 try:
95 obj = client.get(self.request, self.kwargs['object_id'])
96 except Exception:
97 redirect = reverse('horizon:project:x509_certificates:index')
98 msg = _('Unable to retrieve details for x509_certificate "%s".')\
99 % (self.kwargs['object_id'])
100 exceptions.handle(self.request, msg,
101 redirect=redirect)
102 return obj
103
104 @memoized.memoized_method
105 def _get_data_created_date(self, obj):
106 try:
107 created_date = datetime.utcfromtimestamp(obj.created).isoformat()
108 except Exception:
109 redirect = reverse('horizon:project:x509_certificates:index')
110 msg = _('Unable to retrieve details for x509_certificate "%s".')\
111 % (self.kwargs['object_id'])
112 exceptions.handle(self.request, msg,
113 redirect=redirect)
114 return created_date
115
116 @memoized.memoized_method
117 def _get_crypto_obj(self, obj):
118 der_data = obj.get_encoded()
119 return load_der_x509_certificate(der_data, default_backend())
120
121 @memoized.memoized_method
122 def _get_certificate_version(self, obj):
123 return self._get_crypto_obj(obj).version
124
125 @memoized.memoized_method
126 def _get_certificate_fingerprint(self, obj):
127 return binascii.hexlify(
128 self._get_crypto_obj(obj).fingerprint(hashes.SHA256()))
129
130 @memoized.memoized_method
131 def _get_serial_number(self, obj):
132 return self._get_crypto_obj(obj).serial_number
133
134 @memoized.memoized_method
135 def _get_validity_start(self, obj):
136 return self._get_crypto_obj(obj).not_valid_before
137
138 @memoized.memoized_method
139 def _get_validity_end(self, obj):
140 return self._get_crypto_obj(obj).not_valid_after
141
142 @memoized.memoized_method
143 def _get_issuer(self, obj):
144 result = ""
145 issuer = self._get_crypto_obj(obj).issuer
146 for attribute in issuer:
147 result = (result + str(attribute.oid._name) + "=" +
148 str(attribute.value) + ",")
149 return result[:-1]
150
151 @memoized.memoized_method
152 def _get_subject(self, obj):
153 result = ""
154 issuer = self._get_crypto_obj(obj).subject
155 for attribute in issuer:
156 result = (result + str(attribute.oid._name) + "=" +
157 str(attribute.value) + ",")
158 return result[:-1]
159
160 @memoized.memoized_method
161 def _get_data_bytes(self, obj):
162 try:
163 data = self._get_crypto_obj(obj).public_bytes(Encoding.PEM)
164 except Exception:
165 redirect = reverse('horizon:project:x509_certificates:index')
166 msg = _('Unable to retrieve details for x509_certificate "%s".')\
167 % (self.kwargs['object_id'])
168 exceptions.handle(self.request, msg,
169 redirect=redirect)
170 return data
171
172 def get_context_data(self, **kwargs):
173 """Gets the context data for key."""
174 context = super(DetailView, self).get_context_data(**kwargs)
175 obj = self._get_data()
176 context['object'] = obj
177 context['object_created_date'] = self._get_data_created_date(obj)
178 context['object_bytes'] = self._get_data_bytes(obj)
179 context['cert_version'] = self._get_certificate_version(obj)
180 context['cert_fingerprint'] = self._get_certificate_fingerprint(obj)
181 context['cert_serial_number'] = self._get_serial_number(obj)
182 context['cert_validity_start'] = self._get_validity_start(obj)
183 context['cert_validity_end'] = self._get_validity_end(obj)
184 context['cert_issuer'] = self._get_issuer(obj)
185 context['cert_subject'] = self._get_subject(obj)
186 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13# The slug of the panel to be added to HORIZON_CONFIG. Required.
14PANEL = 'x509_certificates'
15# The slug of the panel group the PANEL is associated with.
16PANEL_GROUP = 'key_manager'
17# The slug of the dashboard the PANEL associated with. Required.
18PANEL_DASHBOARD = 'project'
19
20ADD_INSTALLED_APP = ['castellan_ui', ]
21
22# Python panel class of the PANEL to be added.
23ADD_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 @@
1{% extends "horizon/common/_modal.html" %}
2{% block content %}
3 {% if table %}
4 <div class="modal-body">
5 {{ table.render }}
6 </div>
7 {% endif %}
8 <form id="{% block form_id %}{{ form_id }}{% endblock %}"
9 ng-controller="{% block ng_controller %}DummyController{% endblock %}"
10 name="{% block form_name %}{% endblock %}"
11 autocomplete="{% block autocomplete %}{% if form.no_autocomplete %}off{% endif %}{% endblock %}"
12 class="{% block form_class %}{% endblock %}"
13 action="{% block form_action %}{{ submit_url }}{% endblock %}"
14 method="{% block form-method %}POST{% endblock %}"
15 {% block form_validation %}{% endblock %}
16 {% if add_to_field %}data-add-to-field="{{ add_to_field }}"{% endif %} {% block form_attrs %}enctype="multipart/form-data"{% endblock %}>{% csrf_token %}
17 <div class="modal-body clearfix">
18 {% comment %}
19 These fake fields are required to prevent Chrome v34+ from autofilling form.
20 {% endcomment %}
21 {% if form.no_autocomplete %}
22 <div class="fake_credentials" style="display: none">
23 <input type="text" name="fake_email" value="" />
24 <input type="password" name="fake_password" value="" />
25 </div>
26 {% endif %}
27 {% block modal-body %}
28 <div class="row">
29 <div class="col-sm-6">
30 <fieldset>
31 {% include "horizon/common/_form_fields.html" %}
32 </fieldset>
33 </div>
34 <div class="col-sm-6">
35 {% block modal-body-right %}{% endblock %}
36 </div>
37 </div>
38 {% endblock %}
39 </div>
40 <div class="modal-footer">
41 {% block modal-footer %}
42 {% if cancel_url %}
43 <a href="{% block cancel_url %}{{ cancel_url }}{% endblock %}"
44 class="btn btn-default cancel">
45 {{ cancel_label }}
46 </a>
47 {% endif %}
48 <input class="btn btn-primary" type="submit" value="{{ submit_label }}">
49 {% endblock %}
50 </div>
51 </form>
52{% endblock %}
53{% load i18n %}
54
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 @@
1{% extends '_object_import.html' %}
2{% load i18n %}
3
4{% block modal-body-right %}
5 <p>{% trans "X.509 certificates can be imported if they are in Privacy Enhanced Mail (PEM) format." %}</p>
6 <p>{% trans "Your PEM formatted certificate will look like this:" %}</p>
7 <p><pre>-----BEGIN CERTIFICATE-----<br>&lt;base64-encoded data&gt;<br>-----END CERTIFICATE-----</pre></p>
8{% endblock %}
9
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 @@
1{% extends 'base.html' %}
2{% load i18n parse_date %}
3
4{% block title %}{{ page_title }}{% endblock %}
5
6{% block page_header %}
7 {% include "horizon/common/_detail_header.html" %}
8{% endblock %}
9
10{% block main %}
11<div class="detail">
12 <dl class="dl-horizontal">
13 <dt>{% trans "Name" %}</dt>
14 <dd>{{ object.name|default:_("None") }}</dd>
15 <dt>{% trans "Created" %}</dt>
16 <dd>{{ object_created_date|parse_date}}</dd>
17 <dt>{% trans "Certificate Version" %}</dt>
18 <dd>{{ cert_version}}</dd>
19 <dt>{% trans "SHA-256 Fingerprint" %}</dt>
20 <dd>{{ cert_fingerprint}}</dd>
21 <dt>{% trans "Serial Number" %}</dt>
22 <dd>{{ cert_serial_number}}</dd>
23 <dt>{% trans "Valid From" %}</dt>
24 <dd>{{ cert_validity_start }}</dd>
25 <dt>{% trans "Valid To" %}</dt>
26 <dd>{{ cert_validity_end }}</dd>
27 <dt>{% trans "Issuer" %}</dt>
28 <dd>{{ cert_issuer}}</dd>
29 <dt>{% trans "Subject" %}</dt>
30 <dd>{{ cert_subject}}</dd>
31 <dt>{% trans "Raw Certificate Value" %}</dt>
32 <dd>
33 <div style="white-space: pre-wrap; font-family: monospace">{{ object_bytes|default:_("None") }}</div>
34 </dd>
35 </dl>
36</div>
37{% 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 @@
1{% extends 'base.html' %}
2{% load i18n %}
3{% block title %}{{ page_title }}{% endblock %}
4
5{% block main %}
6 {% include '_x509_certificate_import.html' %}
7{% 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 @@
1{% extends 'base.html' %}
2{% load i18n %}
3{% block title %}{% trans "X.509 Certificates" %}{% endblock %}
4
5{% block breadcrumb_nav %}
6 <ol class = "breadcrumb">
7 <li>{% trans "Project" %}</li>
8 <li>{% trans "Key Manager" %}</li>
9 <li class="active">{% trans "X.509 Certificates" %}</li>
10 </ol>
11{% endblock %}
12
13{% block page_header %}
14 <hz-page-header header="{% trans "X.509 Certificates" %}"></hz-page-header>
15{% endblock page_header %}
16
17{% block main %}
18<div class="row">
19 <div class="col-sm-12">
20 {{ x509_certificate_table.render }}
21 </div>
22</div>
23{% endblock %}
diff --git a/castellan_ui/test/content/__init__.py b/castellan_ui/test/content/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/castellan_ui/test/content/__init__.py
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
--- /dev/null
+++ b/castellan_ui/test/content/x509_certificates/__init__.py
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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13import base64
14from django.core.handlers import wsgi
15from django.core.urlresolvers import reverse
16from horizon import messages as horizon_messages
17import mock
18
19from castellan.common.objects import x_509
20from castellan_ui.api import client as api_castellan
21from castellan_ui.test import helpers as tests
22from castellan_ui.test import test_data
23
24INDEX_URL = reverse('horizon:project:x509_certificates:index')
25
26
27class X509CertificatesViewTest(tests.APITestCase):
28
29 def setUp(self):
30 super(X509CertificatesViewTest, self).setUp()
31 self.cert = test_data.x509_cert
32 self.cert_b64_bytes = base64.b64encode(self.cert.get_encoded())
33 self.mock_object(
34 api_castellan, "get", mock.Mock(return_value=self.cert))
35 self.mock_object(api_castellan, "list", mock.Mock(return_value=[]))
36 self.mock_object(horizon_messages, "success")
37 FAKE_ENVIRON = {'REQUEST_METHOD': 'GET', 'wsgi.input': 'fake_input'}
38 self.request = wsgi.WSGIRequest(FAKE_ENVIRON)
39
40 def test_index(self):
41 cert_list = [test_data.x509_cert, test_data.nameless_x509_cert]
42
43 self.mock_object(
44 api_castellan, "list", mock.Mock(return_value=cert_list))
45
46 res = self.client.get(INDEX_URL)
47 self.assertEqual(res.status_code, 200)
48 self.assertTemplateUsed(res, 'x509_certificates.html')
49 api_castellan.list.assert_called_with(mock.ANY, object_type=x_509.X509)
50
51 def test_detail_view(self):
52 url = reverse('horizon:project:x509_certificates:detail',
53 args=[self.cert.id])
54 self.mock_object(
55 api_castellan, "list", mock.Mock(return_value=[self.cert]))
56 self.mock_object(
57 api_castellan, "get", mock.Mock(return_value=self.cert))
58
59 res = self.client.get(url)
60 self.assertContains(
61 res, "<dt>Name</dt>\n <dd>%s</dd>" % self.cert.name, 1, 200)
62 api_castellan.get.assert_called_once_with(mock.ANY, self.cert.id)
63
64 def test_import_cert(self):
65 self.mock_object(
66 api_castellan, "list", mock.Mock(return_value=[self.cert]))
67 url = reverse('horizon:project:x509_certificates:import')
68 self.mock_object(
69 api_castellan, "import_object", mock.Mock(return_value=self.cert))
70
71 cert_input = (
72 u"-----BEGIN CERTIFICATE-----\n" +
73 self.cert_b64_bytes.decode("utf-8") +
74 u"\n-----END CERTIFICATE-----"
75 )
76
77 cert_form_data = {
78 'source_type': 'raw',
79 'name': self.cert.name,
80 'direct_input': cert_input
81 }
82
83 self.client.post(url, cert_form_data)
84
85 api_castellan.import_object.assert_called_once_with(
86 mock.ANY,
87 object_type=x_509.X509,
88 data=self.cert_b64_bytes.decode('utf-8'),
89 name=self.cert.name
90 )
91
92 def test_delete_cert(self):
93 self.mock_object(
94 api_castellan, "list", mock.Mock(return_value=[self.cert]))
95 self.mock_object(api_castellan, "delete")
96
97 cert_form_data = {
98 'action': 'x509_certificate__delete__%s' % self.cert.id
99 }
100
101 res = self.client.post(INDEX_URL, cert_form_data)
102
103 api_castellan.list.assert_called_with(mock.ANY, object_type=x_509.X509)
104 api_castellan.delete.assert_called_once_with(
105 mock.ANY,
106 self.cert.id,
107 )
108 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 @@
1# Licensed under the Apache License, Version 2.0 (the "License"); you may
2# not use this file except in compliance with the License. You may obtain
3# a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10# License for the specific language governing permissions and limitations
11# under the License.
12
13from castellan.common import objects
14from castellan.tests import utils as castellan_utils
15
16x509_cert = objects.x_509.X509(
17 data=castellan_utils.get_certificate_der(),
18 name='test cert',
19 created=1448088699,
20 id=u'00000000-0000-0000-0000-000000000000')
21
22nameless_x509_cert = objects.x_509.X509(
23 data=castellan_utils.get_certificate_der(),
24 name=None,
25 created=1448088699,
26 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
65 65
66[flake8] 66[flake8]
67exclude = .venv,.git,.tox,dist,*lib/python*,*egg,build,node_modules,.tmp 67exclude = .venv,.git,.tox,dist,*lib/python*,*egg,build,node_modules,.tmp
68max-complexity = 20 \ No newline at end of file 68max-complexity = 20