diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py index dde58a02b3..3eafafa740 100644 --- a/openstack_dashboard/api/keystone.py +++ b/openstack_dashboard/api/keystone.py @@ -499,6 +499,44 @@ def user_update_tenant(request, user, project, admin=True): return manager.update(user, project=project) +@profiler.trace +def credential_create(request, user, type, blob, project=None): + manager = keystoneclient(request).credentials + return manager.create(user=user, type=type, blob=blob, project=project) + + +@profiler.trace +def credential_delete(request, credential_id): + manager = keystoneclient(request, admin=True).credentials + return manager.delete(credential_id) + + +@profiler.trace +def credential_get(request, credential_id, admin=True): + manager = keystoneclient(request, admin=admin).credentials + return manager.get(credential_id) + + +@profiler.trace +def credentials_list(request, user=None): + manager = keystoneclient(request).credentials + return manager.list(user=user) + + +@profiler.trace +def credential_update(request, credential_id, user, + type=None, blob=None, project=None): + manager = keystoneclient(request, admin=True).credentials + try: + return manager.update(credential=credential_id, + user=user, + type=type, + blob=blob, + project=project) + except keystone_exceptions.Conflict: + raise exceptions.Conflict() + + @profiler.trace def group_create(request, domain_id, name, description=None): manager = keystoneclient(request, admin=True).groups diff --git a/openstack_dashboard/dashboards/identity/credentials/__init__.py b/openstack_dashboard/dashboards/identity/credentials/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/identity/credentials/forms.py b/openstack_dashboard/dashboards/identity/credentials/forms.py new file mode 100644 index 0000000000..5947e60fca --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/forms.py @@ -0,0 +1,115 @@ +# 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 gettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard.api import keystone + +# Available credential type choices +TYPE_CHOICES = ( + ('totp', _('TOTP')), + ('ec2', _('EC2')), + ('cert', _('cert')), +) + + +class CreateCredentialForm(forms.SelfHandlingForm): + user_name = forms.ThemableChoiceField(label=_('User')) + cred_type = forms.ThemableChoiceField(label=_('Type'), + choices=TYPE_CHOICES) + data = forms.CharField(label=_('Data')) + project = forms.ThemableChoiceField(label=_('Project'), required=False) + failure_url = 'horizon:identity:credentials:index' + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + + users = keystone.user_list(request) + user_choices = [(user.id, user.name) for user in users] + self.fields['user_name'].choices = user_choices + + project_choices = [('', _("Select a project"))] + projects, __ = keystone.tenant_list(request) + for project in projects: + if project.enabled: + project_choices.append((project.id, project.name)) + self.fields['project'].choices = project_choices + + def handle(self, request, data): + try: + params = { + 'user': data['user_name'], + 'type': data["cred_type"], + 'blob': data["data"], + } + if data["project"]: + params['project'] = data['project'] + new_credential = keystone.credential_create(request, **params) + messages.success( + request, _("User credential created successfully.")) + return new_credential + except Exception: + exceptions.handle(request, _('Unable to create user credential.')) + + +class UpdateCredentialForm(forms.SelfHandlingForm): + id = forms.CharField(label=_("ID"), widget=forms.HiddenInput) + user_name = forms.ThemableChoiceField(label=_('User')) + cred_type = forms.ThemableChoiceField(label=_('Type'), + choices=TYPE_CHOICES) + data = forms.CharField(label=_("Data")) + project = forms.ThemableChoiceField(label=_('Project'), required=False) + failure_url = 'horizon:identity:credentials:index' + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + + users = keystone.user_list(request) + user_choices = [(user.id, user.name) for user in users] + self.fields['user_name'].choices = user_choices + + initial = kwargs.get('initial', {}) + cred_type = initial.get('cred_type') + self.fields['cred_type'].initial = cred_type + + # Keystone does not change project to None. If this field is left as + # "Select a project", the project will not be changed. If this field + # is set to another project, the project will be changed. + project_choices = [('', _("Select a project"))] + projects, __ = keystone.tenant_list(request) + for project in projects: + if project.enabled: + project_choices.append((project.id, project.name)) + self.fields['project'].choices = project_choices + + project = initial.get('project_name') + self.fields['project'].initial = project + + def handle(self, request, data): + try: + params = { + 'user': data['user_name'], + 'type': data["cred_type"], + 'blob': data["data"], + } + params['project'] = data['project'] if data['project'] else None + + keystone.credential_update(request, data['id'], **params) + messages.success( + request, _("User credential updated successfully.")) + return True + except Exception: + exceptions.handle(request, _('Unable to update user credential.')) diff --git a/openstack_dashboard/dashboards/identity/credentials/panel.py b/openstack_dashboard/dashboards/identity/credentials/panel.py new file mode 100644 index 0000000000..5cfd8649ab --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/panel.py @@ -0,0 +1,34 @@ +# 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.conf import settings +from django.utils.translation import gettext_lazy as _ + +import horizon + +from openstack_dashboard.api import keystone +from openstack_dashboard.dashboards.identity import dashboard + + +class CredentialsPanel(horizon.Panel): + name = _("User Credentials") + slug = 'credentials' + policy_rules = (("identity", "identity:list_credentials"),) + + def can_access(self, context): + if (settings.OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT and + not keystone.is_domain_admin(context['request'])): + return False + return super().can_access(context) + + +dashboard.Identity.register(CredentialsPanel) diff --git a/openstack_dashboard/dashboards/identity/credentials/tables.py b/openstack_dashboard/dashboards/identity/credentials/tables.py new file mode 100644 index 0000000000..45a21fcf5c --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/tables.py @@ -0,0 +1,103 @@ +# 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 import urls +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy + +from horizon import tables + +from openstack_dashboard.api import keystone +from openstack_dashboard import policy + + +class CreateCredentialAction(tables.LinkAction): + name = "create" + verbose_name = _("Create User Credential") + url = 'horizon:identity:credentials:create' + classes = ("ajax-modal",) + policy_rules = (("identity", "identity:create_credential"),) + icon = "plus" + + +class UpdateCredentialAction(tables.LinkAction): + name = "update" + verbose_name = _("Edit User Credential") + url = 'horizon:identity:credentials:update' + classes = ("ajax-modal",) + policy_rules = (("identity", "identity:update_credential"),) + icon = "pencil" + + +class DeleteCredentialAction(tables.DeleteAction): + help_text = _("Deleted user credentials are not recoverable.") + policy_rules = (("identity", "identity:delete_credential"),) + + @staticmethod + def action_present(count): + return ngettext_lazy( + "Delete User Credential", + "Delete User Credentials", + count + ) + + @staticmethod + def action_past(count): + return ngettext_lazy( + "Deleted User Credential", + "Deleted User Credentials", + count + ) + + def delete(self, request, obj_id): + keystone.credential_delete(request, obj_id) + + +def get_user_link(datum): + if datum.user_id is not None: + return urls.reverse("horizon:identity:users:detail", + args=(datum.user_id,)) + + +def get_project_link(datum, request): + if policy.check((("identity", "identity:get_project"),), + request, target={"project": datum}): + if datum.project_id is not None: + return urls.reverse("horizon:identity:projects:detail", + args=(datum.project_id,)) + + +class CredentialsTable(tables.DataTable): + user_name = tables.WrappingColumn('user_name', + verbose_name=_('User'), + link=get_user_link) + cred_type = tables.WrappingColumn('type', verbose_name=_('Type')) + data = tables.Column('blob', verbose_name=_('Data')) + project_name = tables.WrappingColumn('project_name', + verbose_name=_('Project'), + link=get_project_link) + + def get_object_id(self, datum): + """Identifier of the credential.""" + return datum.id + + def get_object_display(self, datum): + """Display data of the credential.""" + return datum.blob + + class Meta(object): + name = "credentialstable" + verbose_name = _("User Credentials") + table_actions = (CreateCredentialAction, + DeleteCredentialAction) + row_actions = (UpdateCredentialAction, + DeleteCredentialAction) diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html new file mode 100644 index 0000000000..12de21ceaf --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
{% trans "Create a new user credential." %}
+{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}
+{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html new file mode 100644 index 0000000000..1f405874e6 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +{% trans "Edit the credential's details." %}
+{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}
+{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html new file mode 100644 index 0000000000..77b01297b0 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create User Credential" %}{% endblock %} + +{% block main %} + {% include 'identity/credentials/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html new file mode 100644 index 0000000000..79256b563d --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update User Credential" %}{% endblock %} + +{% block main %} + {% include 'identity/credentials/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/tests.py b/openstack_dashboard/dashboards/identity/credentials/tests.py new file mode 100644 index 0000000000..916438a811 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/tests.py @@ -0,0 +1,39 @@ +# 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.urls import reverse + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + +INDEX_URL = reverse('horizon:identity:credentials:index') +INDEX_VIEW_TEMPLATE = 'horizon/common/_data_table_view.html' + + +class UserCredentialsViewTests(test.TestCase): + + def _get_credentials(self, user): + credentials = [cred for cred in self.credentials.list() + if cred.user_id == user.id] + return credentials + + @test.create_mocks({api.keystone: ('credentials_list', + 'user_get', 'tenant_get')}) + def test_index(self): + user = self.users.list()[0] + self.mock_user_get.return_value = user + credentials = self._get_credentials(user) + self.mock_credentials_list.return_value = credentials + + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, INDEX_VIEW_TEMPLATE) + self.assertCountEqual(res.context['table'].data, credentials) diff --git a/openstack_dashboard/dashboards/identity/credentials/urls.py b/openstack_dashboard/dashboards/identity/credentials/urls.py new file mode 100644 index 0000000000..896a42b23e --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/urls.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.urls import re_path + +from openstack_dashboard.dashboards.identity.credentials import views + + +urlpatterns = [ + re_path(r'^$', views.CredentialsView.as_view(), name='index'), + re_path(r'^(?P{% trans "Create a new credential." %}
+{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}
+{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html new file mode 100644 index 0000000000..1f405874e6 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +{% trans "Edit the credential's details." %}
+{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}
+{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html new file mode 100644 index 0000000000..030ce3612e --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Credential" %}{% endblock %} + +{% block main %} + {% include 'settings/credentials/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html new file mode 100644 index 0000000000..ccccacfdb4 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update Credential" %}{% endblock %} + +{% block main %} + {% include 'settings/credentials/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/tests.py b/openstack_dashboard/dashboards/settings/credentials/tests.py new file mode 100644 index 0000000000..219273d90a --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/tests.py @@ -0,0 +1,39 @@ +# 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.urls import reverse + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + +INDEX_URL = reverse('horizon:settings:credentials:index') +INDEX_VIEW_TEMPLATE = 'horizon/common/_data_table_view.html' + + +class CredentialsViewTests(test.TestCase): + + def _get_credentials(self, user): + credentials = [cred for cred in self.credentials.list() + if cred.user_id == user.id] + return credentials + + @test.create_mocks({api.keystone: ('credentials_list', + 'user_get', 'tenant_get')}) + def test_index(self): + user = self.users.list()[0] + self.mock_user_get.return_value = user + credentials = self._get_credentials(user) + self.mock_credentials_list.return_value = credentials + + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, INDEX_VIEW_TEMPLATE) + self.assertCountEqual(res.context['table'].data, credentials) diff --git a/openstack_dashboard/dashboards/settings/credentials/urls.py b/openstack_dashboard/dashboards/settings/credentials/urls.py new file mode 100644 index 0000000000..585ac4cb2f --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/urls.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.urls import re_path + +from openstack_dashboard.dashboards.settings.credentials import views + + +urlpatterns = [ + re_path(r'^$', views.CredentialsView.as_view(), name='index'), + re_path(r'^(?P