Add support for identity provider management

Display the identity provider panel when the setting
OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT is set to True.

Change-Id: Iadf92eb7542013f9c212eccfa372c6335a319841
Implements: blueprint keystone-federation-idp
This commit is contained in:
lin-hua-cheng 2015-11-12 21:19:45 -08:00
parent 9b534612e2
commit 6ecc3d2973
20 changed files with 615 additions and 0 deletions

View File

@ -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``
------------------

View File

@ -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()

View File

@ -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

View File

@ -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())

View File

@ -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)

View File

@ -0,0 +1,9 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Register a identity provider that is trusted by the Identity API to authenticate identities." %}</p>
<p>{% 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 %}</p>
<p>{% blocktrans %}Example: For <tt>mod_shib</tt> this would be <tt>Shib-Identity-Provider</tt>, for <tt>mod_auth_openidc</tt>, this could be <tt>HTTP_OIDC_ISS</tt>.{% endblocktrans %}</p>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Edit the identity provider's details." %}</p>
<p>{% 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 %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Identity Providers" %}{% endblock %}
{% block main %}
{{ table.render }}
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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<identity_provider_id>[^/]+)/update/$',
views.UpdateView.as_view(), name='update'),
url(r'^register/$', views.RegisterView.as_view(), name='register'))

View File

@ -0,0 +1,101 @@
# 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.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from horizon import tables
from horizon.utils import memoized
from openstack_dashboard import api
from openstack_dashboard import policy
from openstack_dashboard.dashboards.identity.identity_providers \
import forms as idp_forms
from openstack_dashboard.dashboards.identity.identity_providers \
import tables as idp_tables
class IndexView(tables.DataTableView):
table_class = idp_tables.IdentityProvidersTable
template_name = 'identity/identity_providers/index.html'
page_title = _("Identity Providers")
def get_data(self):
idps = []
if policy.check((("identity", "identity:list_identity_providers"),),
self.request):
try:
idps = api.keystone.identity_provider_list(self.request)
except Exception:
exceptions.handle(
self.request,
_('Unable to retrieve identity provider list.'))
else:
msg = _("Insufficient privilege level to view identity provider "
"information.")
messages.info(self.request, msg)
return idps
class UpdateView(forms.ModalFormView):
template_name = 'identity/identity_providers/update.html'
modal_header = _("Update Identity Provider")
form_id = "update_identity_providers_form"
form_class = idp_forms.UpdateIdPForm
submit_label = _("Update Identity Provider")
submit_url = "horizon:identity:identity_providers:update"
success_url = reverse_lazy('horizon:identity:identity_providers:index')
page_title = _("Update Identity Provider")
@memoized.memoized_method
def get_object(self):
try:
return api.keystone.identity_provider_get(
self.request,
self.kwargs['identity_provider_id'])
except Exception:
redirect = reverse("horizon:identity:identity_providers:index")
exceptions.handle(self.request,
_('Unable to update identity provider.'),
redirect=redirect)
def get_context_data(self, **kwargs):
context = super(UpdateView, self).get_context_data(**kwargs)
args = (self.get_object().id,)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_initial(self):
idp = self.get_object()
remote_ids = ', '.join(idp.remote_ids)
return {'id': idp.id,
'description': idp.description,
'enabled': idp.enabled,
'remote_ids': remote_ids}
class RegisterView(forms.ModalFormView):
template_name = 'identity/identity_providers/register.html'
modal_header = _("Register Identity Provider")
form_id = "register_identity_provider_form"
form_class = idp_forms.RegisterIdPForm
submit_label = _("Register Identity Provider")
submit_url = reverse_lazy("horizon:identity:identity_providers:register")
success_url = reverse_lazy('horizon:identity:identity_providers:index')
page_title = _("Register Identity Provider")

View File

@ -0,0 +1,8 @@
from django.utils.translation import ugettext_lazy as _
# The slug of the panel group to be added to HORIZON_CONFIG. Required.
PANEL_GROUP = 'federation'
# The display name of the PANEL_GROUP. Required.
PANEL_GROUP_NAME = _('Federation')
# The slug of the dashboard the PANEL_GROUP associated with. Required.
PANEL_GROUP_DASHBOARD = 'identity'

View File

@ -0,0 +1,10 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'identity_providers'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'identity'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'federation'
# Python panel class of the PANEL to be added.
ADD_PANEL = ('openstack_dashboard.dashboards.identity.'
'identity_providers.panel.IdentityProviders')

View File

@ -61,6 +61,12 @@ WEBROOT = '/'
# with Keystone V3. All entities will be created in the default domain.
#OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default'
# 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.
#OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT = False
# Set Console type:
# valid options are "AUTO"(default), "VNC", "SPICE", "RDP", "SERIAL" or None
# Set to None explicitly if you want to deactivate the console.

View File

@ -112,6 +112,7 @@ OPENSTACK_KEYSTONE_DEFAULT_ROLE = "_member_"
OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT = True
OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'test_domain'
OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT = True
OPENSTACK_KEYSTONE_BACKEND = {
'name': 'native',

View File

@ -23,6 +23,7 @@ from keystoneclient.v2_0 import ec2
from keystoneclient.v2_0 import roles
from keystoneclient.v2_0 import tenants
from keystoneclient.v2_0 import users
from keystoneclient.v3.contrib.federation import identity_providers
from keystoneclient.v3 import domains
from keystoneclient.v3 import groups
from keystoneclient.v3 import role_assignments
@ -144,6 +145,8 @@ def data(TEST):
TEST.roles = utils.TestDataContainer()
TEST.ec2 = utils.TestDataContainer()
TEST.identity_providers = utils.TestDataContainer()
admin_role_dict = {'id': '1',
'name': 'admin'}
admin_role = roles.Role(roles.RoleManager, admin_role_dict)
@ -368,3 +371,19 @@ def data(TEST):
"secret": "secret",
"tenant_id": tenant.id})
TEST.ec2.add(access_secret)
idp_dict_1 = {'id': 'idp_1',
'description': 'identiy provider 1',
'enabled': True,
'remote_ids': ['rid_1', 'rid_2']}
idp_1 = identity_providers.IdentityProvider(
identity_providers.IdentityProviderManager,
idp_dict_1)
idp_dict_2 = {'id': 'idp_2',
'description': 'identiy provider 2',
'enabled': True,
'remote_ids': ['rid_3', 'rid_4']}
idp_2 = identity_providers.IdentityProvider(
identity_providers.IdentityProviderManager,
idp_dict_2)
TEST.identity_providers.add(idp_1, idp_2)

View File

@ -0,0 +1,7 @@
---
features:
- >
[`blueprint keystone-federation-idp <https://blueprints.launchpad.net/horizon/+spec/keystone-federation-idp>`_]
Add support for managing keystone identity provider. To enable the panel,
set ``OPENSTACK_KEYSTONE_FEDERATION_MANAGEMENT`` in the local_settting.py to True.