diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index e53042a5f..8f7dba527 100644 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -745,6 +745,19 @@ are using HTTPS, running your Keystone server on a nonstandard port, or using a nonstandard URL scheme you shouldn't need to touch this setting. +``OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT`` +-------------------------------------------- + +.. versionadded:: 9.0.0(Mitaka) + +Default: ``False`` + +Set this to True to enable panels that provide the ability for users to manage +Identity Providers (IdPs) and establish a set of rules to map federation protocol +attributes to Identity API attributes. This extension requires v3.0+ of the +Identity API. + + ``WEBSSO_ENABLED`` ------------------ diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py index bbc41c4c3..0ccfde57c 100644 --- a/openstack_dashboard/api/keystone.py +++ b/openstack_dashboard/api/keystone.py @@ -752,3 +752,46 @@ def keystone_backend_name(): def get_version(): return VERSIONS.active + + +def is_federation_management_enabled(): + return getattr(settings, 'OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT', False) + + +def identity_provider_create(request, idp_id, description=None, + enabled=False, remote_ids=None): + manager = keystoneclient(request, admin=True).federation.identity_providers + try: + return manager.create(id=idp_id, + description=description, + enabled=enabled, + remote_ids=remote_ids) + except keystone_exceptions.Conflict: + raise exceptions.Conflict() + + +def identity_provider_get(request, idp_id): + manager = keystoneclient(request, admin=True).federation.identity_providers + return manager.get(idp_id) + + +def identity_provider_update(request, idp_id, description=None, + enabled=False, remote_ids=None): + manager = keystoneclient(request, admin=True).federation.identity_providers + try: + return manager.update(idp_id, + description=description, + enabled=enabled, + remote_ids=remote_ids) + except keystone_exceptions.Conflict: + raise exceptions.Conflict() + + +def identity_provider_delete(request, idp_id): + manager = keystoneclient(request, admin=True).federation.identity_providers + return manager.delete(idp_id) + + +def identity_provider_list(request): + manager = keystoneclient(request, admin=True).federation.identity_providers + return manager.list() diff --git a/openstack_dashboard/dashboards/identity/identity_providers/__init__.py b/openstack_dashboard/dashboards/identity/identity_providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/identity/identity_providers/forms.py b/openstack_dashboard/dashboards/identity/identity_providers/forms.py new file mode 100644 index 000000000..cb2ef9475 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/forms.py @@ -0,0 +1,114 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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 horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api + + +class RegisterIdPForm(forms.SelfHandlingForm): + id = forms.CharField( + label=_("Identity Provider ID"), + max_length=64, + help_text=_("User-defined unique id to identify the identity " + "provider.")) + remote_ids = forms.CharField( + label=_("Remote IDs"), + required=False, + help_text=_("Comma-delimited list of valid remote IDs from the " + "identity provider.")) + description = forms.CharField( + label=_("Description"), + widget=forms.widgets.Textarea(attrs={'rows': 4}), + required=False) + enabled = forms.BooleanField( + label=_("Enabled"), + required=False, + help_text=_("Indicates whether this identity provider should accept " + "federated authentication requests."), + initial=True) + + def handle(self, request, data): + try: + remote_ids = data["remote_ids"] or [] + if remote_ids: + remote_ids = [rid.strip() for rid in remote_ids.split(',')] + new_idp = api.keystone.identity_provider_create( + request, + data["id"], + description=data["description"], + enabled=data["enabled"], + remote_ids=remote_ids) + messages.success(request, + _("Identity provider registered successfully.")) + return new_idp + except exceptions.Conflict: + msg = _("Unable to register identity provider. Please check that " + "the Identity Provider ID and Remote IDs provided are " + "not already in use.") + messages.error(request, msg) + except Exception: + exceptions.handle(request, + _("Unable to register identity provider.")) + return False + + +class UpdateIdPForm(forms.SelfHandlingForm): + id = forms.CharField( + label=_("Identity Provider ID"), + widget=forms.TextInput(attrs={'readonly': 'readonly'}), + help_text=_("User-defined unique id to identify the identity " + "provider.")) + remote_ids = forms.CharField( + label=_("Remote IDs"), + required=False, + help_text=_("Comma-delimited list of valid remote IDs from the " + "identity provider.")) + description = forms.CharField( + label=_("Description"), + widget=forms.widgets.Textarea(attrs={'rows': 4}), + required=False) + enabled = forms.BooleanField( + label=_("Enabled"), + required=False, + help_text=_("Indicates whether this identity provider should accept " + "federated authentication requests."), + initial=True) + + def handle(self, request, data): + try: + remote_ids = data["remote_ids"] or [] + if remote_ids: + remote_ids = [rid.strip() for rid in remote_ids.split(',')] + api.keystone.identity_provider_update( + request, + data['id'], + description=data["description"], + enabled=data["enabled"], + remote_ids=remote_ids) + messages.success(request, + _("Identity provider updated successfully.")) + return True + except exceptions.Conflict: + msg = _("Unable to update identity provider. Please check that " + "the Remote IDs provided are not already in use.") + messages.error(request, msg) + except Exception: + exceptions.handle(request, + _('Unable to update identity provider.')) + return False diff --git a/openstack_dashboard/dashboards/identity/identity_providers/panel.py b/openstack_dashboard/dashboards/identity/identity_providers/panel.py new file mode 100644 index 000000000..30a7825ee --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/panel.py @@ -0,0 +1,30 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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 + +from openstack_dashboard.api import keystone + + +class IdentityProviders(horizon.Panel): + name = _("Identity Providers") + slug = 'identity_providers' + policy_rules = (("identity", "identity:list_identity_providers"),) + + @staticmethod + def can_register(): + return (keystone.VERSIONS.active >= 3 and + keystone.is_federation_management_enabled()) diff --git a/openstack_dashboard/dashboards/identity/identity_providers/tables.py b/openstack_dashboard/dashboards/identity/identity_providers/tables.py new file mode 100644 index 000000000..745af1227 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/tables.py @@ -0,0 +1,88 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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.template import defaultfilters as filters +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from horizon import tables + +from openstack_dashboard import api + + +class RegisterIdPLink(tables.LinkAction): + name = "register" + verbose_name = _("Register Identity Provider") + url = "horizon:identity:identity_providers:register" + classes = ("ajax-modal",) + icon = "plus" + policy_rules = (("identity", "identity:create_identity_provider"),) + + +class EditIdPLink(tables.LinkAction): + name = "edit" + verbose_name = _("Edit") + url = "horizon:identity:identity_providers:update" + classes = ("ajax-modal",) + icon = "pencil" + policy_rules = (("identity", "identity:update_identity_provider"),) + + +class DeleteIdPsAction(tables.DeleteAction): + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Unregister Identity Provider", + u"Unregister Identity Providers", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Unregistered Identity Provider", + u"Unregistered Identity Providers", + count + ) + policy_rules = (("identity", "identity:delete_identity_provider"),) + + def delete(self, request, obj_id): + api.keystone.identity_provider_delete(request, obj_id) + + +class IdPFilterAction(tables.FilterAction): + def filter(self, table, idps, filter_string): + """Naive case-insensitive search.""" + q = filter_string.lower() + return [idp for idp in idps + if q in idp.ud.lower()] + + +class IdentityProvidersTable(tables.DataTable): + id = tables.Column('id', verbose_name=_('Identity Provider ID')) + description = tables.Column(lambda obj: getattr(obj, 'description', None), + verbose_name=_('Description')) + enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True, + filters=(filters.yesno, filters.capfirst)) + remote_ids = tables.Column( + lambda obj: getattr(obj, 'remote_ids', []), + verbose_name=_("Remote IDs"), + wrap_list=True, + filters=(filters.unordered_list,)) + + class Meta(object): + name = "identity_providers" + verbose_name = _("Identity Providers") + row_actions = (EditIdPLink, DeleteIdPsAction) + table_actions = (IdPFilterAction, RegisterIdPLink, DeleteIdPsAction) diff --git a/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_register.html b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_register.html new file mode 100644 index 000000000..9eb46a3cc --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_register.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
{% trans "Register a identity provider that is trusted by the Identity API to authenticate identities." %}
+{% blocktrans %}Remote IDs are associated with the identity provider and are globally unique. This indicates the header attributes to use for mapping federation protocol attributes to Identity API objects. If no value is provided, the list will be set to empty.{% endblocktrans %}
+{% blocktrans %}Example: For mod_shib this would be Shib-Identity-Provider, for mod_auth_openidc, this could be HTTP_OIDC_ISS.{% endblocktrans %}
+{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_update.html b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_update.html new file mode 100644 index 000000000..b7682214b --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/_update.html @@ -0,0 +1,8 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +{% trans "Edit the identity provider's details." %}
+{% blocktrans %}Remote IDs are associated with the identity provider and are globally unique. This indicates the header attributes to use for mapping federation protocol attributes to Identity API objects. If no value is provided, the list will be set to empty.{% endblocktrans %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/index.html b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/index.html new file mode 100644 index 000000000..24609ff1f --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/index.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Identity Providers" %}{% endblock %} + +{% block main %} + {{ table.render }} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/register.html b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/register.html new file mode 100644 index 000000000..c2708c7a8 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/register.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Register Identity Provider" %}{% endblock %} + +{% block main %} + {% include 'identity/identity_providers/_register.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/update.html b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/update.html new file mode 100644 index 000000000..b8bacff25 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/templates/identity_providers/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update Identity Provider" %}{% endblock %} + +{% block main %} + {% include 'identity/identity_providers/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/identity_providers/tests.py b/openstack_dashboard/dashboards/identity/identity_providers/tests.py new file mode 100644 index 000000000..6c2f7010a --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/tests.py @@ -0,0 +1,111 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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 import http + +from mox3.mox import IgnoreArg # noqa +from mox3.mox import IsA # noqa + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + + +IDPS_INDEX_URL = reverse('horizon:identity:identity_providers:index') +IDPS_REGISTER_URL = reverse('horizon:identity:identity_providers:register') +IDPS_UPDATE_URL = reverse('horizon:identity:identity_providers:update', + args=['idp_1']) + + +class IdPsViewTests(test.BaseAdminViewTests): + @test.create_stubs({api.keystone: ('identity_provider_list',)}) + def test_index(self): + api.keystone.identity_provider_list(IgnoreArg()). \ + AndReturn(self.identity_providers.list()) + + self.mox.ReplayAll() + + res = self.client.get(IDPS_INDEX_URL) + + self.assertTemplateUsed(res, 'identity/identity_providers/index.html') + self.assertItemsEqual(res.context['table'].data, + self.identity_providers.list()) + + @test.create_stubs({api.keystone: ('identity_provider_create', )}) + def test_create(self): + idp = self.identity_providers.first() + + api.keystone.identity_provider_create(IgnoreArg(), + idp.id, + description=idp.description, + enabled=idp.enabled, + remote_ids=idp.remote_ids). \ + AndReturn(idp) + + self.mox.ReplayAll() + + formData = {'method': 'RegisterIdPForm', + 'id': idp.id, + 'description': idp.description, + 'enabled': idp.enabled, + 'remote_ids': ', '.join(idp.remote_ids)} + res = self.client.post(IDPS_REGISTER_URL, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + + @test.create_stubs({api.keystone: ('identity_provider_get', + 'identity_provider_update')}) + def test_update(self): + idp = self.identity_providers.first() + new_description = 'new_idp_desc' + + api.keystone.identity_provider_get(IsA(http.HttpRequest), idp.id). \ + AndReturn(idp) + api.keystone.identity_provider_update(IsA(http.HttpRequest), + idp.id, + description=new_description, + enabled=idp.enabled, + remote_ids=idp.remote_ids). \ + AndReturn(None) + + self.mox.ReplayAll() + + formData = {'method': 'UpdateIdPForm', + 'id': idp.id, + 'description': new_description, + 'enabled': idp.enabled, + 'remote_ids': ', '.join(idp.remote_ids)} + + res = self.client.post(IDPS_UPDATE_URL, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + + @test.create_stubs({api.keystone: ('identity_provider_list', + 'identity_provider_delete')}) + def test_delete(self): + idp = self.identity_providers.first() + + api.keystone.identity_provider_list(IsA(http.HttpRequest)) \ + .AndReturn(self.identity_providers.list()) + api.keystone.identity_provider_delete(IsA(http.HttpRequest), + idp.id).AndReturn(None) + + self.mox.ReplayAll() + + formData = {'action': 'identity_providers__delete__%s' % idp.id} + res = self.client.post(IDPS_INDEX_URL, formData) + + self.assertNoFormErrors(res) diff --git a/openstack_dashboard/dashboards/identity/identity_providers/urls.py b/openstack_dashboard/dashboards/identity/identity_providers/urls.py new file mode 100644 index 000000000..bec5fbb8d --- /dev/null +++ b/openstack_dashboard/dashboards/identity/identity_providers/urls.py @@ -0,0 +1,26 @@ +# Copyright (C) 2015 Yahoo! Inc. All Rights Reserved. +# +# 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.urls import patterns +from django.conf.urls import url + +from openstack_dashboard.dashboards.identity.identity_providers \ + import views + +urlpatterns = patterns( + 'openstack_dashboard.dashboards.identity.identity_providers.views', + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^(?P