diff --git a/keystone/api/__init__.py b/keystone/api/__init__.py index 2c620b0359..9068a9b954 100644 --- a/keystone/api/__init__.py +++ b/keystone/api/__init__.py @@ -15,6 +15,7 @@ from keystone.api import discovery from keystone.api import endpoints from keystone.api import limits from keystone.api import os_ep_filter +from keystone.api import os_federation from keystone.api import os_oauth1 from keystone.api import os_revoke from keystone.api import os_simple_cert @@ -34,6 +35,7 @@ __all__ = ( 'endpoints', 'limits', 'os_ep_filter', + 'os_federation', 'os_oauth1', 'os_revoke', 'os_simple_cert', @@ -54,6 +56,7 @@ __apis__ = ( endpoints, limits, os_ep_filter, + os_federation, os_oauth1, os_revoke, os_simple_cert, diff --git a/keystone/api/_shared/json_home_relations.py b/keystone/api/_shared/json_home_relations.py index b84c2a4c38..fcffa277e5 100644 --- a/keystone/api/_shared/json_home_relations.py +++ b/keystone/api/_shared/json_home_relations.py @@ -57,3 +57,11 @@ os_trust_parameter_rel_func = functools.partial( os_endpoint_policy_resource_rel_func = functools.partial( json_home.build_v3_extension_resource_relation, extension_name='OS-ENDPOINT-POLICY', extension_version='1.0') + +# OS-FEDERATION "extension" +os_federation_resource_rel_func = functools.partial( + json_home.build_v3_extension_resource_relation, + extension_name='OS-FEDERATION', extension_version='1.0') +os_federation_parameter_rel_func = functools.partial( + json_home.build_v3_extension_parameter_relation, + extension_name='OS-FEDERATION', extension_version='1.0') diff --git a/keystone/api/os_federation.py b/keystone/api/os_federation.py new file mode 100644 index 0000000000..df076fba41 --- /dev/null +++ b/keystone/api/os_federation.py @@ -0,0 +1,597 @@ +# 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. + +# This file handles all flask-restful resources for /v3/OS-FEDERATION + +import flask +import flask_restful +from oslo_log import versionutils +from six.moves import http_client + +from keystone.api._shared import json_home_relations +from keystone.common import authorization +from keystone.common import provider_api +from keystone.common import rbac_enforcer +from keystone.common import request +from keystone.common import validation +import keystone.conf +from keystone import exception +import keystone.federation.controllers +from keystone.federation import schema +from keystone.federation import utils +from keystone.server import flask as ks_flask + + +CONF = keystone.conf.CONF +ENFORCER = rbac_enforcer.RBACEnforcer +PROVIDERS = provider_api.ProviderAPIs + + +_build_param_relation = json_home_relations.os_federation_parameter_rel_func +_build_resource_relation = json_home_relations.os_federation_resource_rel_func + +IDP_ID_PARAMETER_RELATION = _build_param_relation(parameter_name='idp_id') +PROTOCOL_ID_PARAMETER_RELATION = _build_param_relation( + parameter_name='protocol_id') +SP_ID_PARAMETER_RELATION = _build_param_relation(parameter_name='sp_id') + + +def _combine_lists_uniquely(a, b): + # it's most likely that only one of these will be filled so avoid + # the combination if possible. + if a and b: + return {x['id']: x for x in a + b}.values() + else: + return a or b + + +class _ResourceBase(ks_flask.ResourceBase): + json_home_resource_rel_func = _build_resource_relation + json_home_parameter_rel_func = _build_param_relation + + @classmethod + def wrap_member(cls, ref, collection_name=None, member_name=None): + cls._add_self_referential_link(ref, collection_name) + cls._add_related_links(ref) + return {member_name or cls.member_key: ref} + + @staticmethod + def _add_related_links(ref): + # Do Nothing, This is in support of child class mechanisms. + pass + + +class IdentityProvidersResource(_ResourceBase): + collection_key = 'identity_providers' + member_key = 'identity_provider' + api_prefix = '/OS-FEDERATION' + _public_parameters = frozenset(['id', 'enabled', 'description', + 'remote_ids', 'links', 'domain_id' + ]) + _id_path_param_name_override = 'idp_id' + + @staticmethod + def _add_related_links(ref): + """Add URLs for entities related with Identity Provider. + + Add URLs pointing to: + - protocols tied to the Identity Provider + + """ + base_path = ref['links'].get('self') + if base_path is None: + base_path = '/'.join(ks_flask.base_url(path='/%s' % ref['id'])) + + for name in ['protocols']: + ref['links'][name] = '/'.join([base_path, name]) + + def get(self, idp_id=None): + if idp_id is not None: + return self._get_idp(idp_id) + return self._list_idps() + + def _get_idp(self, idp_id): + """Get an IDP resource. + + GET/HEAD /OS-FEDERATION/identity_providers/{idp_id} + """ + ENFORCER.enforce_call(action='identity:get_identity_provider') + ref = PROVIDERS.federation_api.get_idp(idp_id) + return self.wrap_member(ref) + + def _list_idps(self): + """List all identity providers. + + GET/HEAD /OS-FEDERATION/identity_providers + """ + filters = ['id', 'enabled'] + ENFORCER.enforce_call(action='identity:list_identity_providers', + filters=filters) + hints = self.build_driver_hints(filters) + refs = PROVIDERS.federation_api.list_idps(hints=hints) + refs = [self.filter_params(r) for r in refs] + collection = self.wrap_collection(refs, hints=hints) + for r in collection[self.collection_key]: + # Add the related links explicitly + self._add_related_links(r) + return collection + + def put(self, idp_id): + """Create an idp resource for federated authentication. + + PUT /OS-FEDERATION/identity_providers/{idp_id} + """ + ENFORCER.enforce_call(action='identity:create_identity_provider') + idp = self.request_body_json.get('identity_provider', {}) + validation.lazy_validate(schema.identity_provider_create, + idp) + idp = self._normalize_dict(idp) + idp.setdefault('enabled', False) + idp_ref = PROVIDERS.federation_api.create_idp( + idp_id, idp) + return self.wrap_member(idp_ref), http_client.CREATED + + def patch(self, idp_id): + ENFORCER.enforce_call(action='identity:update_identity_provider') + idp = self.request_body_json.get('identity_provider', {}) + validation.lazy_validate(schema.identity_provider_update, idp) + idp = self._normalize_dict(idp) + idp_ref = PROVIDERS.federation_api.update_idp( + idp_id, idp) + return self.wrap_member(idp_ref) + + def delete(self, idp_id): + ENFORCER.enforce_call(action='identity:delete_identity_provider') + PROVIDERS.federation_api.delete_idp(idp_id) + return None, http_client.NO_CONTENT + + +class IdentityProvidersProtocolsResource(_ResourceBase): + collection_key = 'protocols' + member_key = 'protocol' + _public_parameters = frozenset(['id', 'mapping_id', 'links']) + api_prefix = '/OS-FEDERATION/identity_providers/' + json_home_additional_parameters = { + 'idp_id': IDP_ID_PARAMETER_RELATION} + json_home_collection_resource_name_override = 'identity_provider_protocols' + json_home_member_resource_name_override = 'identity_provider_protocol' + + @staticmethod + def _add_related_links(ref): + """Add new entries to the 'links' subdictionary in the response. + + Adds 'identity_provider' key with URL pointing to related identity + provider as a value. + + :param ref: response dictionary + + """ + ref.setdefault('links', {}) + ref['links']['identity_provider'] = ks_flask.base_url( + path=ref['idp_id']) + + def get(self, idp_id, protocol_id=None): + if protocol_id is not None: + return self._get_protocol(idp_id, protocol_id) + return self._list_protocols(idp_id) + + def _get_protocol(self, idp_id, protocol_id): + """Get protocols for an IDP. + + HEAD/GET /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + """ + ENFORCER.enforce_call(action='identity:get_protocol') + ref = PROVIDERS.federation_api.get_protocol(idp_id, protocol_id) + return self.wrap_member(ref) + + def _list_protocols(self, idp_id): + """List protocols for an IDP. + + HEAD/GET /OS-FEDERATION/identity_providers/{idp_id}/protocols + """ + ENFORCER.enforce_call(action='identity:list_protocols') + protocol_refs = PROVIDERS.federation_api.list_protocols(idp_id) + protocols = list(protocol_refs) + collection = self.wrap_collection(protocols) + for r in collection[self.collection_key]: + # explicitly add related links + self._add_related_links(r) + return collection + + def put(self, idp_id, protocol_id): + """Create protocol for an IDP. + + PUT /OS-Federation/identity_providers/{idp_id}/protocols/{protocol_id} + """ + ENFORCER.enforce_call(action='identity:create_protocol') + protocol = self.request_body_json.get('protocol', {}) + validation.lazy_validate(schema.protocol_create, protocol) + protocol = self._normalize_dict(protocol) + ref = PROVIDERS.federation_api.create_protocol(idp_id, protocol_id, + protocol) + return self.wrap_member(ref), http_client.CREATED + + def patch(self, idp_id, protocol_id): + """Update protocol for an IDP. + + PATCH /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + """ + ENFORCER.enforce_call(action='identity:update_protocol') + protocol = self.request_body_json.get('protocol', {}) + validation.lazy_validate(schema.protocol_update, protocol) + ref = PROVIDERS.federation_api.update_protocol(idp_id, protocol_id, + protocol) + return self.wrap_member(ref) + + def delete(self, idp_id, protocol_id): + """Delete a protocol from an IDP. + + DELETE /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + """ + ENFORCER.enforce_call(action='identity:delete_protocol') + PROVIDERS.federation_api.delete_protocol(idp_id, protocol_id) + return None, http_client.NO_CONTENT + + +class MappingResource(_ResourceBase): + collection_key = 'mappings' + member_key = 'mapping' + api_prefix = '/OS-FEDERATION' + + def get(self, mapping_id=None): + if mapping_id is not None: + return self._get_mapping(mapping_id) + return self._list_mappings() + + def _get_mapping(self, mapping_id): + """Get a mapping. + + HEAD/GET /OS-FEDERATION/mappings/{mapping_id} + """ + ENFORCER.enforce_call(action='identity:get_mapping') + return self.wrap_member(PROVIDERS.federation_api.get_mapping( + mapping_id)) + + def _list_mappings(self): + """List mappings. + + HEAD/GET /OS-FEDERATION/mappings + """ + ENFORCER.enforce_call(action='identity:list_mappings') + return self.wrap_collection(PROVIDERS.federation_api.list_mappings()) + + def put(self, mapping_id): + """Create a mapping. + + PUT /OS-FEDERATION/mappings/{mapping_id} + """ + ENFORCER.enforce_call(action='identity:create_mapping') + mapping = self.request_body_json.get('mapping', {}) + mapping = self._normalize_dict(mapping) + utils.validate_mapping_structure(mapping) + mapping_ref = PROVIDERS.federation_api.create_mapping( + mapping_id, mapping) + return self.wrap_member(mapping_ref), http_client.CREATED + + def patch(self, mapping_id): + """Update a mapping. + + PATCH /OS-FEDERATION/mappings/{mapping_id} + """ + ENFORCER.enforce_call(action='identity:update_mapping') + mapping = self.request_body_json.get('mapping', {}) + mapping = self._normalize_dict(mapping) + utils.validate_mapping_structure(mapping) + mapping_ref = PROVIDERS.federation_api.update_mapping( + mapping_id, mapping) + return self.wrap_member(mapping_ref) + + def delete(self, mapping_id): + """Delete a mapping. + + DELETE /OS-FEDERATION/mappings/{mapping_id} + """ + ENFORCER.enforce_call(action='identity:delete_mapping') + PROVIDERS.federation_api.delete_mapping(mapping_id) + return None, http_client.NO_CONTENT + + +class ServiceProvidersResource(_ResourceBase): + collection_key = 'service_providers' + member_key = 'service_provider' + _public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description', + 'links', 'relay_state_prefix', 'sp_url']) + _id_path_param_name_override = 'sp_id' + api_prefix = '/OS-FEDERATION' + + def get(self, sp_id=None): + if sp_id is not None: + return self._get_service_provider(sp_id) + return self._list_service_providers() + + def _get_service_provider(self, sp_id): + """Get a service provider. + + GET/HEAD /OS-FEDERATION/service_providers/{sp_id} + """ + ENFORCER.enforce_call(action='identity:get_service_provider') + return self.wrap_member(PROVIDERS.federation_api.get_sp(sp_id)) + + def _list_service_providers(self): + """List service providers. + + GET/HEAD /OS-FEDERATION/service_providers + """ + filters = ['id', 'enabled'] + ENFORCER.enforce_call(action='identity:list_service_providers', + filters=filters) + hints = self.build_driver_hints(filters) + refs = [self.filter_params(r) + for r in + PROVIDERS.federation_api.list_sps(hints=hints)] + return self.wrap_collection(refs, hints=hints) + + def put(self, sp_id): + """Create a service provider. + + PUT /OS-FEDERATION/service_providers/{sp_id} + """ + ENFORCER.enforce_call(action='identity:create_service_provider') + sp = self.request_body_json.get('service_provider', {}) + validation.lazy_validate(schema.service_provider_create, sp) + sp = self._normalize_dict(sp) + sp.setdefault('enabled', False) + sp.setdefault('relay_state_prefix', + CONF.saml.relay_state_prefix) + sp_ref = PROVIDERS.federation_api.create_sp(sp_id, sp) + return self.wrap_member(sp_ref), http_client.CREATED + + def patch(self, sp_id): + """Update a service provider. + + PATCH /OS-FEDERATION/service_providers/{sp_id} + """ + ENFORCER.enforce_call(action='identity:update_service_provider') + sp = self.request_body_json.get('service_provider', {}) + validation.lazy_validate(schema.service_provider_update, sp) + sp = self._normalize_dict(sp) + sp_ref = PROVIDERS.federation_api.update_sp(sp_id, sp) + return self.wrap_member(sp_ref) + + def delete(self, sp_id): + """Delete a service provider. + + DELETE /OS-FEDERATION/service_providers/{sp_id} + """ + ENFORCER.enforce_call(action='identity:delete_service_provider') + PROVIDERS.federation_api.delete_sp(sp_id) + return None, http_client.NO_CONTENT + + +class OSFederationProjectResource(flask_restful.Resource): + @versionutils.deprecated(as_of=versionutils.deprecated.JUNO, + what='GET /v3/OS-FEDERATION/projects', + in_favor_of='GET /v3/auth/projects') + def get(self): + """Get projects for user. + + GET/HEAD /OS-FEDERATION/projects + """ + ENFORCER.enforce_call(action='identity:get_auth_projects') + # TODO(morgan): Make this method simply call the endpoint for + # /v3/auth/projects once auth is ported to flask. + auth_context = flask.request.environ.get( + authorization.AUTH_CONTEXT_ENV) + user_id = auth_context.get('user_id') + group_ids = auth_context.get('group_ids') + + user_refs = [] + if user_id: + try: + user_refs = PROVIDERS.assignment_api.list_projects_for_user( + user_id) + except exception.UserNotFound: # nosec + # federated users have an id but they don't link to anything + pass + group_refs = [] + if group_ids: + group_refs = PROVIDERS.assignment_api.list_projects_for_groups( + group_ids) + refs = _combine_lists_uniquely(user_refs, group_refs) + return ks_flask.ResourceBase.wrap_collection( + refs, collection_name='projects') + + +class OSFederationDomainResource(flask_restful.Resource): + @versionutils.deprecated(as_of=versionutils.deprecated.JUNO, + what='GET /v3/OS-FEDERATION/domains', + in_favor_of='GET /v3/auth/domains') + def get(self): + """Get domains for user. + + GET/HEAD /OS-FEDERATION/domains + """ + ENFORCER.enforce_call(action='identity:get_auth_domains') + # TODO(morgan): Make this method simply call the endpoint for + # /v3/auth/domains once auth is ported to flask. + auth_context = flask.request.environ.get( + authorization.AUTH_CONTEXT_ENV) + user_id = auth_context.get('user_id') + group_ids = auth_context.get('group_ids') + + user_refs = [] + if user_id: + try: + user_refs = PROVIDERS.assignment_api.list_domains_for_user( + user_id) + except exception.UserNotFound: # nosec + # federated users have an ide bu they don't link to anything + pass + group_refs = [] + if group_ids: + group_refs = PROVIDERS.assignment_api.list_domains_for_groups( + group_ids) + refs = _combine_lists_uniquely(user_refs, group_refs) + return ks_flask.ResourceBase.wrap_collection( + refs, collection_name='domains') + + +class SAML2MetadataResource(flask_restful.Resource): + @ks_flask.unenforced_api + def get(self): + """Get SAML2 metadata. + + GET/HEAD /OS-FEDERATION/saml2/metadata + """ + metadata_path = CONF.saml.idp_metadata_path + try: + with open(metadata_path, 'r') as metadata_handler: + metadata = metadata_handler.read() + except IOError as e: + # Raise HTTP 500 in case Metadata file cannot be read. + raise exception.MetadataFileError(reason=e) + resp = flask.make_response(metadata, http_client.OK) + resp.headers['Content-Type'] = 'text/xml' + return resp + + +class OSFederationAuthResource(flask_restful.Resource): + def _construct_webob_request(self): + # Build a fake(ish) webob request object from the flask request state + # to pass to the Auth Controller's authenticate_for_token. This is + # purely transitional code. + return request.Request(flask.request.environ) + + @ks_flask.unenforced_api + def get(self, idp_id, protocol_id): + """Authenticate from dedicated uri endpoint. + + GET/HEAD /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id}/auth + """ + return self._auth(idp_id, protocol_id) + + @ks_flask.unenforced_api + def post(self, idp_id, protocol_id): + """Authenticate from dedicated uri endpoint. + + POST /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id}/auth + """ + return self._auth(idp_id, protocol_id) + + def _auth(self, idp_id, protocol_id): + """Build and pass auth data to auth controller. + + Build HTTP request body for federated authentication and inject + it into the ``authenticate_for_token`` function. + """ + compat_controller = keystone.federation.controllers.Auth() + auth = { + 'identity': { + 'methods': [protocol_id], + protocol_id: { + 'identity_provider': idp_id, + 'protocol': protocol_id + }, + } + } + # NOTE(morgan): for compatibility, make sure we use a webob request + # until /auth is ported to flask. Since this is a webob response, + # deconstruct it and turn it into a flask response. + webob_resp = compat_controller.authenticate_for_token( + self._construct_webob_request(), auth) + flask_resp = flask.make_response( + webob_resp.body, webob_resp.status_code) + flask_resp.headers.extend(webob_resp.headers.dict_of_lists()) + return flask_resp + + +class OSFederationAPI(ks_flask.APIBase): + _name = 'OS-FEDERATION' + _import_name = __name__ + _api_url_prefix = '/OS-FEDERATION' + resources = [] + resource_mapping = [ + ks_flask.construct_resource_map( + # NOTE(morgan): No resource relation here, the resource relation is + # to /v3/auth/domains and /v3/auth/projects + resource=OSFederationDomainResource, + url='/domains', + resource_kwargs={}), + ks_flask.construct_resource_map( + # NOTE(morgan): No resource relation here, the resource relation is + # to /v3/auth/domains and /v3/auth/projects + resource=OSFederationProjectResource, + url='/projects', + resource_kwargs={}), + ks_flask.construct_resource_map( + resource=SAML2MetadataResource, + url='/saml2/metadata', + resource_kwargs={}, + rel='metadata', + resource_relation_func=_build_resource_relation), + ks_flask.construct_resource_map( + resource=OSFederationAuthResource, + url=('/identity_providers//protocols/' + '/auth'), + resource_kwargs={}, + rel='identity_provider_protocol_auth', + resource_relation_func=_build_resource_relation, + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION}), + ] + + +class OSFederationIdentityProvidersAPI(ks_flask.APIBase): + _name = 'identity_providers' + _import_name = __name__ + _api_url_prefix = '/OS-FEDERATION' + resources = [IdentityProvidersResource] + resource_mapping = [] + + +class OSFederationIdentityProvidersProtocolsAPI(ks_flask.APIBase): + _name = 'protocols' + _import_name = __name__ + _api_url_prefix = '/OS-FEDERATION/identity_providers/' + resources = [IdentityProvidersProtocolsResource] + resource_mapping = [] + + +class OSFederationMappingsAPI(ks_flask.APIBase): + _name = 'mappings' + _import_name = __name__ + _api_url_prefix = '/OS-FEDERATION' + resources = [MappingResource] + resource_mapping = [] + + +class OSFederationServiceProvidersAPI(ks_flask.APIBase): + _name = 'service_providers' + _import_name = __name__ + _api_url_prefix = '/OS-FEDERATION' + resources = [ServiceProvidersResource] + resource_mapping = [] + + +APIs = ( + OSFederationAPI, + OSFederationIdentityProvidersAPI, + OSFederationIdentityProvidersProtocolsAPI, + OSFederationMappingsAPI, + OSFederationServiceProvidersAPI +) diff --git a/keystone/auth/routers.py b/keystone/auth/routers.py index 4804aaf40d..379d0c263a 100644 --- a/keystone/auth/routers.py +++ b/keystone/auth/routers.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +from keystone.api._shared import json_home_relations from keystone.auth import controllers from keystone.common import json_home from keystone.common import wsgi @@ -57,6 +58,17 @@ class Routers(wsgi.RoutersBase): path='/auth/domains', get_head_action='get_auth_domains', rel=json_home.build_v3_resource_relation('auth_domains')) + # NOTE(morgan): explicitly add json_home data for auth_projects and + # auth_domains for OS-FEDERATION here, as auth will always own it + # based upon how the flask scaffolding works. This bit is transitional + # for the move to flask. + for element in ['projects', 'domains']: + resource_data = {'href': '/auth/%s' % element} + json_home.Status.update_resource_data( + resource_data, status=json_home.Status.STABLE) + json_home.JsonHomeResources.append_resource( + json_home_relations.os_federation_resource_rel_func( + resource_name=element), resource_data) self._add_resource( mapper, auth_controller, diff --git a/keystone/federation/controllers.py b/keystone/federation/controllers.py index 86bb259284..96dce6d103 100644 --- a/keystone/federation/controllers.py +++ b/keystone/federation/controllers.py @@ -15,7 +15,6 @@ import string from oslo_log import log -from oslo_log import versionutils from six.moves import http_client from six.moves import urllib import webob @@ -49,217 +48,6 @@ class _ControllerBase(controller.V3Controller): return super(_ControllerBase, cls).base_url(context, path=path) -class IdentityProvider(_ControllerBase): - """Identity Provider representation.""" - - collection_name = 'identity_providers' - member_name = 'identity_provider' - - _public_parameters = frozenset(['id', 'enabled', 'description', - 'remote_ids', 'links', 'domain_id' - ]) - - @classmethod - def _add_related_links(cls, context, ref): - """Add URLs for entities related with Identity Provider. - - Add URLs pointing to: - - protocols tied to the Identity Provider - - """ - ref.setdefault('links', {}) - base_path = ref['links'].get('self') - if base_path is None: - base_path = '/'.join([IdentityProvider.base_url(context), - ref['id']]) - for name in ['protocols']: - ref['links'][name] = '/'.join([base_path, name]) - - @classmethod - def _add_self_referential_link(cls, context, ref): - id = ref['id'] - self_path = '/'.join([cls.base_url(context), id]) - ref.setdefault('links', {}) - ref['links']['self'] = self_path - - @classmethod - def wrap_member(cls, context, ref): - cls._add_self_referential_link(context, ref) - cls._add_related_links(context, ref) - ref = cls.filter_params(ref) - return {cls.member_name: ref} - - @controller.protected() - def create_identity_provider(self, request, idp_id, identity_provider): - validation.lazy_validate(schema.identity_provider_create, - identity_provider) - identity_provider = self._normalize_dict(identity_provider) - identity_provider.setdefault('enabled', False) - idp_ref = PROVIDERS.federation_api.create_idp( - idp_id, identity_provider - ) - response = IdentityProvider.wrap_member(request.context_dict, idp_ref) - return wsgi.render_response( - body=response, status=(http_client.CREATED, - http_client.responses[http_client.CREATED])) - - @controller.filterprotected('id', 'enabled') - def list_identity_providers(self, request, filters): - hints = self.build_driver_hints(request, filters) - ref = PROVIDERS.federation_api.list_idps(hints=hints) - ref = [self.filter_params(x) for x in ref] - return IdentityProvider.wrap_collection(request.context_dict, - ref, hints=hints) - - @controller.protected() - def get_identity_provider(self, request, idp_id): - ref = PROVIDERS.federation_api.get_idp(idp_id) - return IdentityProvider.wrap_member(request.context_dict, ref) - - @controller.protected() - def delete_identity_provider(self, request, idp_id): - PROVIDERS.federation_api.delete_idp(idp_id) - - @controller.protected() - def update_identity_provider(self, request, idp_id, identity_provider): - validation.lazy_validate(schema.identity_provider_update, - identity_provider) - identity_provider = self._normalize_dict(identity_provider) - idp_ref = PROVIDERS.federation_api.update_idp( - idp_id, identity_provider - ) - return IdentityProvider.wrap_member(request.context_dict, idp_ref) - - -class FederationProtocol(_ControllerBase): - """A federation protocol representation. - - See keystone.common.controller.V3Controller docstring for explanation - on _public_parameters class attributes. - - """ - - collection_name = 'protocols' - member_name = 'protocol' - - _public_parameters = frozenset(['id', 'mapping_id', 'links']) - - @classmethod - def _add_self_referential_link(cls, context, ref): - """Add 'links' entry to the response dictionary. - - Calls IdentityProvider.base_url() class method, as it constructs - proper URL along with the 'identity providers' part included. - - :param ref: response dictionary - - """ - ref.setdefault('links', {}) - base_path = ref['links'].get('identity_provider') - if base_path is None: - base_path = [IdentityProvider.base_url(context), ref['idp_id']] - base_path = '/'.join(base_path) - self_path = [base_path, 'protocols', ref['id']] - self_path = '/'.join(self_path) - ref['links']['self'] = self_path - - @classmethod - def _add_related_links(cls, context, ref): - """Add new entries to the 'links' subdictionary in the response. - - Adds 'identity_provider' key with URL pointing to related identity - provider as a value. - - :param ref: response dictionary - - """ - ref.setdefault('links', {}) - base_path = '/'.join([IdentityProvider.base_url(context), - ref['idp_id']]) - ref['links']['identity_provider'] = base_path - - @classmethod - def wrap_member(cls, context, ref): - cls._add_related_links(context, ref) - cls._add_self_referential_link(context, ref) - ref = cls.filter_params(ref) - return {cls.member_name: ref} - - @controller.protected() - def create_protocol(self, request, idp_id, protocol_id, protocol): - validation.lazy_validate(schema.protocol_create, protocol) - protocol = self._normalize_dict(protocol) - ref = PROVIDERS.federation_api.create_protocol( - idp_id, protocol_id, protocol) - response = FederationProtocol.wrap_member(request.context_dict, ref) - return wsgi.render_response( - body=response, status=(http_client.CREATED, - http_client.responses[http_client.CREATED])) - - @controller.protected() - def update_protocol(self, request, idp_id, protocol_id, protocol): - validation.lazy_validate(schema.protocol_update, protocol) - protocol = self._normalize_dict(protocol) - ref = PROVIDERS.federation_api.update_protocol(idp_id, protocol_id, - protocol) - return FederationProtocol.wrap_member(request.context_dict, ref) - - @controller.protected() - def get_protocol(self, request, idp_id, protocol_id): - ref = PROVIDERS.federation_api.get_protocol(idp_id, protocol_id) - return FederationProtocol.wrap_member(request.context_dict, ref) - - @controller.protected() - def list_protocols(self, request, idp_id): - protocols_ref = PROVIDERS.federation_api.list_protocols(idp_id) - protocols = list(protocols_ref) - return FederationProtocol.wrap_collection(request.context_dict, - protocols) - - @controller.protected() - def delete_protocol(self, request, idp_id, protocol_id): - PROVIDERS.federation_api.delete_protocol(idp_id, protocol_id) - - -class MappingController(_ControllerBase): - collection_name = 'mappings' - member_name = 'mapping' - - @controller.protected() - def create_mapping(self, request, mapping_id, mapping): - ref = self._normalize_dict(mapping) - utils.validate_mapping_structure(ref) - mapping_ref = PROVIDERS.federation_api.create_mapping(mapping_id, ref) - response = MappingController.wrap_member(request.context_dict, - mapping_ref) - return wsgi.render_response( - body=response, status=(http_client.CREATED, - http_client.responses[http_client.CREATED])) - - @controller.protected() - def list_mappings(self, request): - ref = PROVIDERS.federation_api.list_mappings() - return MappingController.wrap_collection(request.context_dict, ref) - - @controller.protected() - def get_mapping(self, request, mapping_id): - ref = PROVIDERS.federation_api.get_mapping(mapping_id) - return MappingController.wrap_member(request.context_dict, ref) - - @controller.protected() - def delete_mapping(self, request, mapping_id): - PROVIDERS.federation_api.delete_mapping(mapping_id) - - @controller.protected() - def update_mapping(self, request, mapping_id, mapping): - mapping = self._normalize_dict(mapping) - utils.validate_mapping_structure(mapping) - mapping_ref = PROVIDERS.federation_api.update_mapping( - mapping_id, mapping - ) - return MappingController.wrap_member(request.context_dict, mapping_ref) - - class Auth(auth_controllers.Auth): def _get_sso_origin_host(self, request): @@ -436,117 +224,3 @@ class Auth(auth_controllers.Auth): body=ecp_assertion.to_string(), status=(http_client.OK, http_client.responses[http_client.OK]), headers=headers) - - -class DomainV3(controller.V3Controller): - collection_name = 'domains' - member_name = 'domain' - - def __init__(self): - super(DomainV3, self).__init__() - self.get_member_from_driver = PROVIDERS.resource_api.get_domain - - @versionutils.deprecated( - as_of=versionutils.deprecated.JUNO, - in_favor_of='GET /v3/auth/domains/', - ) - @controller.protected() - def list_domains_for_user(self, request): - """List all domains available to an authenticated user. - - :param context: request context - :returns: list of accessible domains - - """ - controller = auth_controllers.Auth() - return controller.get_auth_domains(request) - - -class ProjectAssignmentV3(controller.V3Controller): - collection_name = 'projects' - member_name = 'project' - - def __init__(self): - super(ProjectAssignmentV3, self).__init__() - self.get_member_from_driver = PROVIDERS.resource_api.get_project - - @versionutils.deprecated( - as_of=versionutils.deprecated.JUNO, - in_favor_of='GET /v3/auth/projects/', - ) - @controller.protected() - def list_projects_for_user(self, request): - """List all projects available to an authenticated user. - - :param context: request context - :returns: list of accessible projects - - """ - controller = auth_controllers.Auth() - return controller.get_auth_projects(request) - - -class ServiceProvider(_ControllerBase): - """Service Provider representation.""" - - collection_name = 'service_providers' - member_name = 'service_provider' - - _public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description', - 'links', 'relay_state_prefix', 'sp_url']) - - @controller.protected() - def create_service_provider(self, request, sp_id, service_provider): - validation.lazy_validate(schema.service_provider_create, - service_provider) - service_provider = self._normalize_dict(service_provider) - service_provider.setdefault('enabled', False) - service_provider.setdefault('relay_state_prefix', - CONF.saml.relay_state_prefix) - sp_ref = PROVIDERS.federation_api.create_sp(sp_id, service_provider) - response = ServiceProvider.wrap_member(request.context_dict, sp_ref) - return wsgi.render_response( - body=response, status=(http_client.CREATED, - http_client.responses[http_client.CREATED])) - - @controller.filterprotected('id', 'enabled') - def list_service_providers(self, request, filters): - hints = self.build_driver_hints(request, filters) - ref = PROVIDERS.federation_api.list_sps(hints=hints) - ref = [self.filter_params(x) for x in ref] - return ServiceProvider.wrap_collection(request.context_dict, - ref, hints=hints) - - @controller.protected() - def get_service_provider(self, request, sp_id): - ref = PROVIDERS.federation_api.get_sp(sp_id) - return ServiceProvider.wrap_member(request.context_dict, ref) - - @controller.protected() - def delete_service_provider(self, request, sp_id): - PROVIDERS.federation_api.delete_sp(sp_id) - - @controller.protected() - def update_service_provider(self, request, sp_id, service_provider): - validation.lazy_validate(schema.service_provider_update, - service_provider) - service_provider = self._normalize_dict(service_provider) - sp_ref = PROVIDERS.federation_api.update_sp(sp_id, service_provider) - return ServiceProvider.wrap_member(request.context_dict, sp_ref) - - -class SAMLMetadataV3(_ControllerBase): - member_name = 'metadata' - - def get_metadata(self, context): - metadata_path = CONF.saml.idp_metadata_path - try: - with open(metadata_path, 'r') as metadata_handler: - metadata = metadata_handler.read() - except IOError as e: - # Raise HTTP 500 in case Metadata file cannot be read. - raise exception.MetadataFileError(reason=e) - return wsgi.render_response( - body=metadata, status=(http_client.OK, - http_client.responses[http_client.OK]), - headers=[('Content-Type', 'text/xml')]) diff --git a/keystone/federation/routers.py b/keystone/federation/routers.py index 8b07f53766..9bfd11740b 100644 --- a/keystone/federation/routers.py +++ b/keystone/federation/routers.py @@ -36,54 +36,6 @@ class Routers(wsgi.RoutersBase): The API looks like:: - PUT /OS-FEDERATION/identity_providers/{idp_id} - GET /OS-FEDERATION/identity_providers - HEAD /OS-FEDERATION/identity_providers - GET /OS-FEDERATION/identity_providers/{idp_id} - HEAD /OS-FEDERATION/identity_providers/{idp_id} - DELETE /OS-FEDERATION/identity_providers/{idp_id} - PATCH /OS-FEDERATION/identity_providers/{idp_id} - - PUT /OS-FEDERATION/identity_providers/ - {idp_id}/protocols/{protocol_id} - GET /OS-FEDERATION/identity_providers/ - {idp_id}/protocols - HEAD /OS-FEDERATION/identity_providers/ - {idp_id}/protocols - GET /OS-FEDERATION/identity_providers/ - {idp_id}/protocols/{protocol_id} - HEAD /OS-FEDERATION/identity_providers/ - {idp_id}/protocols/{protocol_id} - PATCH /OS-FEDERATION/identity_providers/ - {idp_id}/protocols/{protocol_id} - DELETE /OS-FEDERATION/identity_providers/ - {idp_id}/protocols/{protocol_id} - - PUT /OS-FEDERATION/mappings - GET /OS-FEDERATION/mappings - HEAD /OS-FEDERATION/mappings - PATCH /OS-FEDERATION/mappings/{mapping_id} - GET /OS-FEDERATION/mappings/{mapping_id} - HEAD /OS-FEDERATION/mappings/{mapping_id} - DELETE /OS-FEDERATION/mappings/{mapping_id} - - GET /OS-FEDERATION/projects - HEAD /OS-FEDERATION/projects - GET /OS-FEDERATION/domains - HEAD /OS-FEDERATION/domains - - PUT /OS-FEDERATION/service_providers/{sp_id} - GET /OS-FEDERATION/service_providers - HEAD /OS-FEDERATION/service_providers - GET /OS-FEDERATION/service_providers/{sp_id} - HEAD /OS-FEDERATION/service_providers/{sp_id} - DELETE /OS-FEDERATION/service_providers/{sp_id} - PATCH /OS-FEDERATION/service_providers/{sp_id} - - GET /OS-FEDERATION/identity_providers/{idp_id}/ - protocols/{protocol_id}/auth - POST /OS-FEDERATION/identity_providers/{idp_id}/ - protocols/{protocol_id}/auth GET /auth/OS-FEDERATION/identity_providers/ {idp_id}/protocols/{protocol_id}/websso ?origin=https%3A//horizon.example.com @@ -94,8 +46,6 @@ class Routers(wsgi.RoutersBase): POST /auth/OS-FEDERATION/saml2 POST /auth/OS-FEDERATION/saml2/ecp - GET /OS-FEDERATION/saml2/metadata - HEAD /OS-FEDERATION/saml2/metadata GET /auth/OS-FEDERATION/websso/{protocol_id} ?origin=https%3A//horizon.example.com @@ -105,131 +55,15 @@ class Routers(wsgi.RoutersBase): """ - _path_prefixes = ('auth', 'OS-FEDERATION') + _path_prefixes = ('auth',) def _construct_url(self, suffix): return "/OS-FEDERATION/%s" % suffix def append_v3_routers(self, mapper, routers): auth_controller = controllers.Auth() - idp_controller = controllers.IdentityProvider() - protocol_controller = controllers.FederationProtocol() - mapping_controller = controllers.MappingController() - project_controller = controllers.ProjectAssignmentV3() - domain_controller = controllers.DomainV3() - saml_metadata_controller = controllers.SAMLMetadataV3() - sp_controller = controllers.ServiceProvider() - - # Identity Provider CRUD operations - - self._add_resource( - mapper, idp_controller, - path=self._construct_url('identity_providers/{idp_id}'), - get_head_action='get_identity_provider', - put_action='create_identity_provider', - patch_action='update_identity_provider', - delete_action='delete_identity_provider', - rel=build_resource_relation(resource_name='identity_provider'), - path_vars={ - 'idp_id': IDP_ID_PARAMETER_RELATION, - }) - self._add_resource( - mapper, idp_controller, - path=self._construct_url('identity_providers'), - get_head_action='list_identity_providers', - rel=build_resource_relation(resource_name='identity_providers')) - - # Protocol CRUD operations - - self._add_resource( - mapper, protocol_controller, - path=self._construct_url('identity_providers/{idp_id}/protocols/' - '{protocol_id}'), - get_head_action='get_protocol', - put_action='create_protocol', - patch_action='update_protocol', - delete_action='delete_protocol', - rel=build_resource_relation( - resource_name='identity_provider_protocol'), - path_vars={ - 'idp_id': IDP_ID_PARAMETER_RELATION, - 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, - }) - self._add_resource( - mapper, protocol_controller, - path=self._construct_url('identity_providers/{idp_id}/protocols'), - get_head_action='list_protocols', - rel=build_resource_relation( - resource_name='identity_provider_protocols'), - path_vars={ - 'idp_id': IDP_ID_PARAMETER_RELATION, - }) - - # Mapping CRUD operations - - self._add_resource( - mapper, mapping_controller, - path=self._construct_url('mappings/{mapping_id}'), - get_head_action='get_mapping', - put_action='create_mapping', - patch_action='update_mapping', - delete_action='delete_mapping', - rel=build_resource_relation(resource_name='mapping'), - path_vars={ - 'mapping_id': build_parameter_relation( - parameter_name='mapping_id'), - }) - self._add_resource( - mapper, mapping_controller, - path=self._construct_url('mappings'), - get_head_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_head_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_head_action='list_service_providers', - rel=build_resource_relation(resource_name='service_providers')) - - self._add_resource( - mapper, domain_controller, - path=self._construct_url('domains'), - new_path='/auth/domains', - get_head_action='list_domains_for_user', - rel=build_resource_relation(resource_name='domains')) - self._add_resource( - mapper, project_controller, - path=self._construct_url('projects'), - new_path='/auth/projects', - get_head_action='list_projects_for_user', - rel=build_resource_relation(resource_name='projects')) # Auth operations - self._add_resource( - mapper, auth_controller, - path=self._construct_url('identity_providers/{idp_id}/' - 'protocols/{protocol_id}/auth'), - get_post_action='federated_authentication', - rel=build_resource_relation( - resource_name='identity_provider_protocol_auth'), - path_vars={ - 'idp_id': IDP_ID_PARAMETER_RELATION, - 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, - }) self._add_resource( mapper, auth_controller, path='/auth' + self._construct_url('saml2'), @@ -253,15 +87,9 @@ class Routers(wsgi.RoutersBase): path='/auth' + self._construct_url( 'identity_providers/{idp_id}/protocols/{protocol_id}/websso'), get_post_action='federated_idp_specific_sso_auth', - rel=build_resource_relation(resource_name='identity_providers'), + rel=build_resource_relation( + resource_name='identity_providers_websso'), path_vars={ 'idp_id': IDP_ID_PARAMETER_RELATION, 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, }) - - # Keystone-Identity-Provider metadata endpoint - self._add_resource( - mapper, saml_metadata_controller, - path=self._construct_url('saml2/metadata'), - get_head_action='get_metadata', - rel=build_resource_relation(resource_name='metadata')) diff --git a/keystone/server/flask/application.py b/keystone/server/flask/application.py index d084c862d7..8f6daa94ee 100644 --- a/keystone/server/flask/application.py +++ b/keystone/server/flask/application.py @@ -42,6 +42,7 @@ _MOVED_API_PREFIXES = frozenset( 'endpoints', 'OS-OAUTH1', 'OS-EP-FILTER', + 'OS-FEDERATION', 'OS-REVOKE', 'OS-SIMPLE-CERT', 'OS-TRUST', diff --git a/keystone/server/flask/common.py b/keystone/server/flask/common.py index 9836ca76bf..d8eec1a837 100644 --- a/keystone/server/flask/common.py +++ b/keystone/server/flask/common.py @@ -317,6 +317,7 @@ class APIBase(object): c_key = getattr(r, 'collection_key', None) m_key = getattr(r, 'member_key', None) r_pfx = getattr(r, 'api_prefix', None) + if not c_key or not m_key: LOG.debug('Unable to add resource %(resource)s to API ' '%(name)s, both `member_key` and `collection_key` ' @@ -336,13 +337,20 @@ class APIBase(object): # NOTE(morgan): The Prefix is automatically added by the API, so # we do not add it to the paths here. collection_path = '/%s' % c_key - entity_path = '/%(collection)s/' % { - 'collection': c_key, 'member': m_key} + if getattr(r, '_id_path_param_name_override', None): + # The member_key doesn't match the "id" key in the url, make + # sure to use the correct path-key for ID. + member_id_key = getattr(r, '_id_path_param_name_override') + else: + member_id_key = '%(member_key)s_id' % {'member_key': m_key} + + entity_path = '/%(collection)s/' % { + 'collection': c_key, 'member': member_id_key} # NOTE(morgan): The json-home form of the entity path is different # from the flask-url routing form. Must also include the prefix - jh_e_path = '%(pfx)s/%(e_path)s' % { + jh_e_path = _URL_SUBST.sub('{\\1}', '%(pfx)s/%(e_path)s' % { 'pfx': self._api_url_prefix, - 'e_path': _URL_SUBST.sub('{\\1}', entity_path).lstrip('/')} + 'e_path': entity_path.lstrip('/')}) LOG.debug( 'Adding standard routes to API %(name)s for `%(resource)s` ' @@ -360,16 +368,37 @@ class APIBase(object): json_home.build_v3_resource_relation) resource_rel_status = getattr( r, 'json_home_resource_status', None) - collection_rel = resource_rel_func(resource_name=c_key) + collection_rel_resource_name = getattr( + r, 'json_home_collection_resource_name_override', c_key) + collection_rel = resource_rel_func( + resource_name=collection_rel_resource_name) # NOTE(morgan): Add the prefix explicitly for JSON Home documents # to the collection path. - rel_data = {'href': '%(pfx)s%(collection_path)s' % { + href_val = '%(pfx)s%(collection_path)s' % { 'pfx': self._api_url_prefix, 'collection_path': collection_path} - } - entity_rel = resource_rel_func(resource_name=m_key) - id_str = '%s_id' % m_key + # If additional parameters exist in the URL, add them to the + # href-vars dict. + additional_params = getattr( + r, 'json_home_additional_parameters', {}) + + if additional_params: + # NOTE(morgan): Special case, we have 'additional params' which + # means we know the params are in the "prefix". This guarantees + # the correct data in the json_home document with href-template + # and href-vars even on the "collection" entry + rel_data = dict() + rel_data['href-template'] = _URL_SUBST.sub('{\\1}', href_val) + rel_data['href-vars'] = additional_params + else: + rel_data = {'href': href_val} + member_rel_resource_name = getattr( + r, 'json_home_member_resource_name_override', m_key) + + entity_rel = resource_rel_func( + resource_name=member_rel_resource_name) + id_str = member_id_key parameter_rel_func = getattr( r, 'json_home_parameter_rel_func', @@ -378,6 +407,10 @@ class APIBase(object): entity_rel_data = {'href-template': jh_e_path, 'href-vars': {id_str: id_param_rel}} + if additional_params: + entity_rel_data.setdefault('href-vars', {}).update( + additional_params) + if resource_rel_status is not None: json_home.Status.update_resource_data( rel_data, resource_rel_status) @@ -535,9 +568,11 @@ class ResourceBase(flask_restful.Resource): collection_key = None member_key = None + _public_parameters = frozenset([]) # NOTE(morgan): This must match the string on the API the resource is # registered to. api_prefix = '' + _id_path_param_name_override = None method_decorators = [] @@ -565,6 +600,25 @@ class ResourceBase(flask_restful.Resource): if ref.get('id') is not None and id_arg != ref['id']: raise exception.ValidationError('Cannot change ID') + @classmethod + def filter_params(cls, ref): + """Remove unspecified parameters from the dictionary. + + This function removes unspecified parameters from the dictionary. + This method checks only root-level keys from a ref dictionary. + + :param ref: a dictionary representing deserialized response to be + serialized + """ + # NOTE(morgan): if _public_parameters is empty, do nothing. We do not + # filter if we do not have an explicit white-list to work from. + if cls._public_parameters: + ref_keys = set(ref.keys()) + blocked_keys = ref_keys - cls._public_parameters + for blocked_param in blocked_keys: + del ref[blocked_param] + return ref + @classmethod def wrap_collection(cls, refs, hints=None, collection_name=None): """Wrap a collection, checking for filtering and pagination. diff --git a/keystone/tests/unit/test_versions.py b/keystone/tests/unit/test_versions.py index cc33b8b60e..8dc18159a6 100644 --- a/keystone/tests/unit/test_versions.py +++ b/keystone/tests/unit/test_versions.py @@ -391,7 +391,7 @@ V3_JSON_HOME_RESOURCES = { { 'href-template': '/OS-FEDERATION/identity_providers/{idp_id}', 'href-vars': {'idp_id': IDP_ID_PARAMETER_RELATION, }}, - _build_federation_rel(resource_name='identity_providers'): { + _build_federation_rel(resource_name='identity_providers_websso'): { 'href-template': FEDERATED_IDP_SPECIFIC_WEBSSO, 'href-vars': { 'idp_id': IDP_ID_PARAMETER_RELATION, @@ -799,9 +799,14 @@ class VersionTestCase(unit.TestCase): self.assertThat(resp.status, tt_matchers.Equals('200 OK')) self.assertThat(resp.headers['Content-Type'], tt_matchers.Equals('application/json-home')) - - self.assertThat(jsonutils.loads(resp.body), - tt_matchers.Equals(exp_json_home_data)) + maxDiff = self.maxDiff + self.maxDiff = None + # NOTE(morgan): Changed from tt_matchers.Equals to make it easier to + # determine issues. Reset maxDiff to the original value at the end + # of the assert. + self.assertDictEqual(exp_json_home_data, + jsonutils.loads(resp.body)) + self.maxDiff = maxDiff def test_json_home_v3(self): # If the request is /v3 and the Accept header is application/json-home