Service Providers API for OS-FEDERATION

For new Keystone2Keystone federation usecase Keystone needs to
manage trusted Service Providers. This patch implements
CRUD API calls for that.

Implements blueprint: k2k-service-providers

Change-Id: I69673045009926365803a89a2e4b0d892d841ed6
This commit is contained in:
Marek Denis 2014-07-03 19:34:24 +02:00 committed by Steve Martinelli
parent 5cf219382b
commit 96747bc484
10 changed files with 418 additions and 5 deletions

View File

@ -148,6 +148,12 @@
"identity:delete_mapping": "rule:admin_required",
"identity:update_mapping": "rule:admin_required",
"identity:create_service_provider": "rule:admin_required",
"identity:list_service_providers": "rule:admin_required",
"identity:get_service_provider": "rule:admin_required",
"identity:update_service_provider": "rule:admin_required",
"identity:delete_service_provider": "rule:admin_required",
"identity:get_auth_catalog": "",
"identity:get_auth_projects": "",
"identity:get_auth_domains": "",

View File

@ -161,6 +161,12 @@
"identity:delete_mapping": "rule:cloud_admin",
"identity:update_mapping": "rule:cloud_admin",
"identity:create_service_provider": "rule:cloud_admin",
"identity:list_service_providers": "rule:cloud_admin",
"identity:get_service_provider": "rule:cloud_admin",
"identity:update_service_provider": "rule:cloud_admin",
"identity:delete_service_provider": "rule:cloud_admin",
"identity:get_auth_catalog": "",
"identity:get_auth_projects": "",
"identity:get_auth_domains": "",

View File

@ -57,8 +57,8 @@ class IdentityProviderModel(sql.ModelBase, sql.DictBase):
new_dictionary = dictionary.copy()
return cls(**new_dictionary)
def to_dict(self, include_extra_dict=False):
"""Return the model's attributes as a dictionary."""
def to_dict(self):
"""Return a dictionary with model's attributes."""
d = dict()
for attr in self.__class__.attributes:
d[attr] = getattr(self, attr)
@ -85,6 +85,31 @@ class MappingModel(sql.ModelBase, sql.DictBase):
return d
class ServiceProviderModel(sql.ModelBase, sql.DictBase):
__tablename__ = 'service_provider'
attributes = ['auth_url', 'id', 'enabled', 'description', 'sp_url']
mutable_attributes = frozenset(['auth_url', 'description', 'enabled',
'sp_url'])
id = sql.Column(sql.String(64), primary_key=True)
enabled = sql.Column(sql.Boolean, nullable=False)
description = sql.Column(sql.Text(), nullable=True)
auth_url = sql.Column(sql.String(256), nullable=True)
sp_url = sql.Column(sql.String(256), nullable=True)
@classmethod
def from_dict(cls, dictionary):
new_dictionary = dictionary.copy()
return cls(**new_dictionary)
def to_dict(self):
"""Return a dictionary with model's attributes."""
d = dict()
for attr in self.__class__.attributes:
d[attr] = getattr(self, attr)
return d
class Federation(core.Driver):
# Identity Provider CRUD
@ -241,3 +266,45 @@ class Federation(core.Driver):
mapping_id = protocol_ref.mapping_id
mapping_ref = self._get_mapping(session, mapping_id)
return mapping_ref.to_dict()
# Service Provider CRUD
@sql.handle_conflicts(conflict_type='service_provider')
def create_sp(self, sp_id, sp):
with sql.transaction() as session:
sp['id'] = sp_id
sp_ref = ServiceProviderModel.from_dict(sp)
session.add(sp_ref)
return sp_ref.to_dict()
def delete_sp(self, sp_id):
with sql.transaction() as session:
sp_ref = self._get_sp(session, sp_id)
session.delete(sp_ref)
def _get_sp(self, session, sp_id):
sp_ref = session.query(ServiceProviderModel).get(sp_id)
if not sp_ref:
raise exception.ServiceProviderNotFound(sp_id=sp_id)
return sp_ref
def list_sps(self):
session = sql.get_session()
with sql.transaction() as session:
sps = session.query(ServiceProviderModel)
sps_list = [sp.to_dict() for sp in sps]
return sps_list
def get_sp(self, sp_id):
with sql.transaction() as session:
sp_ref = self._get_sp(session, sp_id)
return sp_ref.to_dict()
def update_sp(self, sp_id, sp):
with sql.transaction() as session:
sp_ref = self._get_sp(session, sp_id)
old_sp = sp_ref.to_dict()
old_sp.update(sp)
new_sp = ServiceProviderModel.from_dict(old_sp)
for attr in ServiceProviderModel.mutable_attributes:
setattr(sp_ref, attr, getattr(new_sp, attr))
return sp_ref.to_dict()

View File

@ -337,6 +337,50 @@ class ProjectV3(controller.V3Controller):
return ProjectV3.wrap_collection(context, projects)
@dependency.requires('federation_api')
class ServiceProvider(_ControllerBase):
"""Service Provider representation."""
collection_name = 'service_providers'
member_name = 'service_provider'
_mutable_parameters = frozenset(['auth_url', 'description', 'enabled',
'sp_url'])
_public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description',
'links', 'sp_url'])
@controller.protected()
def create_service_provider(self, context, sp_id, service_provider):
service_provider = self._normalize_dict(service_provider)
service_provider.setdefault('enabled', False)
ServiceProvider.check_immutable_params(service_provider)
sp_ref = self.federation_api.create_sp(sp_id, service_provider)
response = ServiceProvider.wrap_member(context, sp_ref)
return wsgi.render_response(body=response, status=('201', 'Created'))
@controller.protected()
def list_service_providers(self, context):
ref = self.federation_api.list_sps()
ref = [self.filter_params(x) for x in ref]
return ServiceProvider.wrap_collection(context, ref)
@controller.protected()
def get_service_provider(self, context, sp_id):
ref = self.federation_api.get_sp(sp_id)
return ServiceProvider.wrap_member(context, ref)
@controller.protected()
def delete_service_provider(self, context, sp_id):
self.federation_api.delete_sp(sp_id)
@controller.protected()
def update_service_provider(self, context, sp_id, service_provider):
service_provider = self._normalize_dict(service_provider)
ServiceProvider.check_immutable_params(service_provider)
sp_ref = self.federation_api.update_sp(sp_id, service_provider)
return ServiceProvider.wrap_member(context, sp_ref)
class SAMLMetadataV3(_ControllerBase):
member_name = 'metadata'

View File

@ -224,3 +224,70 @@ class Driver(object):
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def create_sp(self, sp_id, sp):
"""Create a service provider.
:param sp_id: id of the service provider
:type sp_id: string
:param sp: service prvider object
:type sp: dict
:returns: sp_ref
:rtype: dict
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def delete_sp(self, sp_id):
"""Delete a service provider.
:param sp_id: id of the service provider
:type sp_id: string
:raises: keystone.exception.ServiceProviderNotFound
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def list_sps(self):
"""List all service providers.
:returns List of sp_ref objects
:rtype: list of dicts
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def get_sp(self, sp_id):
"""Get a service provider.
:param sp_id: id of the service provider
:type sp_id: string
:returns: sp_ref
:raises: keystone.exception.ServiceProviderNotFound
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def update_sp(self, sp_id, sp):
"""Update a service provider.
:param sp_id: id of the service provider
:type sp_id: string
:param sp: service prvider object
:type sp: dict
:returns: sp_ref
:rtype: dict
:raises: keystone.exception.ServiceProviderNotFound
"""
raise exception.NotImplemented() # pragma: no cover

View File

@ -0,0 +1,38 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import sqlalchemy as sql
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
sp_table = sql.Table(
'service_provider',
meta,
sql.Column('auth_url', sql.String(256), nullable=True),
sql.Column('id', sql.String(64), primary_key=True),
sql.Column('enabled', sql.Boolean, nullable=False),
sql.Column('description', sql.Text(), nullable=True),
sql.Column('sp_url', sql.String(256), nullable=True),
mysql_engine='InnoDB',
mysql_charset='utf8')
sp_table.create(migrate_engine, checkfirst=True)
def downgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
table = sql.Table('service_provider', meta, autoload=True)
table.drop()

View File

@ -29,6 +29,7 @@ build_parameter_relation = functools.partial(
IDP_ID_PARAMETER_RELATION = build_parameter_relation(parameter_name='idp_id')
PROTOCOL_ID_PARAMETER_RELATION = build_parameter_relation(
parameter_name='protocol_id')
SP_ID_PARAMETER_RELATION = build_parameter_relation(parameter_name='sp_id')
class FederationExtension(wsgi.V3ExtensionRouter):
@ -62,17 +63,20 @@ class FederationExtension(wsgi.V3ExtensionRouter):
GET /OS-FEDERATION/projects
GET /OS-FEDERATION/domains
PUT /OS-FEDERATION/service_providers/$service_provider
GET /OS-FEDERATION/service_providers
GET /OS-FEDERATION/service_providers/$service_provider
DELETE /OS-FEDERATION/service_providers/$service_provider
PATCH /OS-FEDERATION/service_providers/$service_provider
GET /OS-FEDERATION/identity_providers/$identity_provider/
protocols/$protocol/auth
POST /OS-FEDERATION/identity_providers/$identity_provider/
protocols/$protocol/auth
POST /auth/OS-FEDERATION/saml2
GET /OS-FEDERATION/saml2/metadata
"""
def _construct_url(self, suffix):
return "/OS-FEDERATION/%s" % suffix
@ -88,6 +92,7 @@ class FederationExtension(wsgi.V3ExtensionRouter):
project_controller = controllers.ProjectV3()
domain_controller = controllers.DomainV3()
saml_metadata_controller = controllers.SAMLMetadataV3()
sp_controller = controllers.ServiceProvider()
# Identity Provider CRUD operations
@ -153,6 +158,27 @@ class FederationExtension(wsgi.V3ExtensionRouter):
path=self._construct_url('mappings'),
get_action='list_mappings',
rel=build_resource_relation(resource_name='mappings'))
# Service Providers CRUD operations
self._add_resource(
mapper, sp_controller,
path=self._construct_url('service_providers/{sp_id}'),
get_action='get_service_provider',
put_action='create_service_provider',
patch_action='update_service_provider',
delete_action='delete_service_provider',
rel=build_resource_relation(resource_name='service_provider'),
path_vars={
'sp_id': SP_ID_PARAMETER_RELATION,
})
self._add_resource(
mapper, sp_controller,
path=self._construct_url('service_providers'),
get_action='list_service_providers',
rel=build_resource_relation(resource_name='service_providers'))
self._add_resource(
mapper, domain_controller,
path=self._construct_url('domains'),

View File

@ -307,6 +307,10 @@ class IdentityProviderNotFound(NotFound):
message_format = _("Could not find Identity Provider: %(idp_id)s")
class ServiceProviderNotFound(NotFound):
message_format = _("Could not find Service Provider: %(sp_id)s")
class FederatedProtocolNotFound(NotFound):
message_format = _("Could not find federated protocol %(protocol_id)s for"
" Identity Provider: %(idp_id)s")

View File

@ -36,3 +36,11 @@ class SqlFederation(test_backend_sql.SqlModels):
cols = (('id', sql.String, 64),
('rules', sql.JsonBlob, None))
self.assertExpectedSchema('mapping', cols)
def test_service_provider(self):
cols = (('auth_url', sql.String, 256),
('id', sql.String, 64),
('enabled', sql.Boolean, None),
('description', sql.Text, None),
('sp_url', sql.String, 256))
self.assertExpectedSchema('service_provider', cols)

View File

@ -2356,3 +2356,150 @@ class IdPMetadataGenerationTests(FederationTests):
reference_file = _load_xml('idp_saml2_metadata.xml')
self.assertEqual(reference_file, r.result)
class ServiceProviderTests(FederationTests):
"""A test class for Service Providers."""
MEMBER_NAME = 'service_provider'
COLLECTION_NAME = 'service_providers'
SERVICE_PROVIDER_ID = 'ACME'
SP_KEYS = ['auth_url', 'id', 'enabled', 'description', 'sp_url']
def setUp(self):
super(FederationTests, self).setUp()
# Add a Service Provider
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
self.SP_REF = self.sp_ref()
self.SERVICE_PROVIDER = self.put(
url, body={'service_provider': self.SP_REF},
expected_status=201).result
def sp_ref(self):
ref = {
'auth_url': 8 * uuid.uuid4().hex,
'enabled': True,
'description': uuid.uuid4().hex,
'sp_url': 8 * uuid.uuid4().hex
}
return ref
def base_url(self, suffix=None):
if suffix is not None:
return '/OS-FEDERATION/service_providers/' + str(suffix)
return '/OS-FEDERATION/service_providers'
def test_get_service_provider(self):
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
resp = self.get(url, expected_status=200)
self.assertValidEntity(resp.result['service_provider'],
keys_to_check=self.SP_KEYS)
def test_get_service_provider_fail(self):
url = self.base_url(suffix=uuid.uuid4().hex)
self.get(url, expected_status=404)
def test_create_service_provider(self):
url = self.base_url(suffix=uuid.uuid4().hex)
sp = self.sp_ref()
resp = self.put(url, body={'service_provider': sp},
expected_status=201)
self.assertValidEntity(resp.result['service_provider'],
keys_to_check=self.SP_KEYS)
def test_create_service_provider_fail(self):
"""Try adding SP object with unallowed attribute."""
url = self.base_url(suffix=uuid.uuid4().hex)
sp = self.sp_ref()
sp[uuid.uuid4().hex] = uuid.uuid4().hex
self.put(url, body={'service_provider': sp},
expected_status=403)
def test_list_service_providers(self):
"""Test listing of service provider objects.
Add two new service providers. List all available service providers.
Expect to get list of three service providers (one created by setUp())
Test if attributes match.
"""
ref_service_providers = {
uuid.uuid4().hex: self.sp_ref(),
uuid.uuid4().hex: self.sp_ref(),
}
for id, sp in ref_service_providers.items():
url = self.base_url(suffix=id)
self.put(url, body={'service_provider': sp}, expected_status=201)
# Insert ids into service provider object, we will compare it with
# responses from server and those include 'id' attribute.
ref_service_providers[self.SERVICE_PROVIDER_ID] = self.SP_REF
for id, sp in ref_service_providers.items():
sp['id'] = id
url = self.base_url()
resp = self.get(url)
service_providers = resp.result
for service_provider in service_providers['service_providers']:
id = service_provider['id']
self.assertValidEntity(
service_provider, ref=ref_service_providers[id],
keys_to_check=self.SP_KEYS)
def test_update_service_provider(self):
"""Update existing service provider.
Update default existing service provider and make sure it has been
properly change.
"""
new_sp_ref = self.sp_ref()
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
resp = self.patch(url, body={'service_provider': new_sp_ref},
expected_status=200)
patch_result = resp.result
new_sp_ref['id'] = self.SERVICE_PROVIDER_ID
self.assertValidEntity(patch_result['service_provider'],
ref=new_sp_ref,
keys_to_check=self.SP_KEYS)
resp = self.get(url, expected_status=200)
get_result = resp.result
self.assertDictEqual(patch_result['service_provider'],
get_result['service_provider'])
def test_update_service_provider_immutable_parameters(self):
"""Update immutable attributes in service provider.
In this particular case the test will try to change ``id`` attribute.
Expectet server to return error code
"""
new_sp_ref = {'id': uuid.uuid4().hex}
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
self.patch(url, body={'service_provider': new_sp_ref},
expected_status=403)
def test_update_service_provider_unknown_parameter(self):
new_sp_ref = self.sp_ref()
new_sp_ref[uuid.uuid4().hex] = uuid.uuid4().hex
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
self.patch(url, body={'service_provider': new_sp_ref},
expected_status=403)
def test_update_service_provider_404(self):
new_sp_ref = self.sp_ref()
new_sp_ref['description'] = uuid.uuid4().hex
url = self.base_url(suffix=uuid.uuid4().hex)
self.patch(url, body={'service_provider': new_sp_ref},
expected_status=404)
def test_delete_service_provider(self):
url = self.base_url(suffix=self.SERVICE_PROVIDER_ID)
self.delete(url, expected_status=204)
def test_delete_service_provider_404(self):
url = self.base_url(suffix=uuid.uuid4().hex)
self.delete(url, expected_status=404)