From d1784a7a24a35113a544ff2695a017e0d2f4ebc2 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Wed, 7 Apr 2021 01:11:54 +0200 Subject: [PATCH 1/4] feat: DecideBackedByTarget microservice --- .../target_based_routing.yaml.example | 12 ++ src/satosa/micro_services/custom_routing.py | 109 ++++++++++++++++++ .../micro_services/test_custom_routing.py | 64 +++++++++- 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 example/plugins/microservices/target_based_routing.yaml.example diff --git a/example/plugins/microservices/target_based_routing.yaml.example b/example/plugins/microservices/target_based_routing.yaml.example new file mode 100644 index 000000000..7b017dba8 --- /dev/null +++ b/example/plugins/microservices/target_based_routing.yaml.example @@ -0,0 +1,12 @@ +module: satosa.micro_services.custom_routing.DecideBackendByTargetIdP +name: TargetRouter +config: + default_backend: Saml2 + + # the regex that will intercept http requests to be handled with this microservice + endpoint_paths: + - ".*/disco" + + target_mapping: + "http://idpspid.testunical.it:8088": "spidSaml2" # map SAML entity with entity id 'target_id' to backend name + "http://eidas.testunical.it:8081/saml2/metadata": "eidasSaml2" diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index d903502be..1eaccea5d 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -2,14 +2,123 @@ from base64 import urlsafe_b64encode from satosa.context import Context +from satosa.internal import InternalData + from .base import RequestMicroService from ..exception import SATOSAConfigurationError from ..exception import SATOSAError +from ..exception import SATOSAStateError logger = logging.getLogger(__name__) +class CustomRoutingError(SATOSAError): + """ + SATOSA exception raised by CustomRouting rules + """ + pass + + +class DecideBackendByTargetIdP(RequestMicroService): + """ + Select which backend should be used based on who is the SAML IDP + """ + + def __init__(self, config:dict, *args, **kwargs): + """ + Constructor. + :param config: microservice configuration loaded from yaml file + :type config: Dict[str, Dict[str, str]] + """ + super().__init__(*args, **kwargs) + self.target_mapping = config['target_mapping'] + self.endpoint_paths = config['endpoint_paths'] + self.default_backend = config['default_backend'] + + if not isinstance(self.endpoint_paths, list): + raise SATOSAConfigurationError() + + def register_endpoints(self): + """ + URL mapping of additional endpoints this micro service needs to register for callbacks. + + Example of a mapping from the url path '/callback' to the callback() method of a micro service: + reg_endp = [ + ("^/callback1$", self.callback), + ] + + :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] + + :return: A list with functions and args bound to a specific endpoint url, + [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] + """ + + # this intercepts disco response + return [ + (path , self.backend_by_entityid) + for path in self.endpoint_paths + ] + + def _get_request_entity_id(self, context): + return ( + context.get_decoration(Context.KEY_TARGET_ENTITYID) or + context.request.get('entityID') + ) + + def _get_backend(self, context:Context, entity_id:str) -> str: + """ + returns the Target Backend to use + """ + return ( + self.target_mapping.get(entity_id) or + self.default_backend + ) + + def process(self, context:Context, data:dict): + """ + Will modify the context.target_backend attribute based on the target entityid. + :param context: request context + :param data: the internal request + """ + entity_id = self._get_request_entity_id(context) + if entity_id: + self._rewrite_context(entity_id, context) + return super().process(context, data) + + def _rewrite_context(self, entity_id:str, context:Context) -> None: + tr_backend = self._get_backend(context, entity_id) + context.decorate(Context.KEY_TARGET_ENTITYID, entity_id) + context.target_frontend = context.target_frontend or context.state.get('ROUTER') + native_backend = context.target_backend + msg = (f'Found DecideBackendByTarget ({self.name} microservice) ' + f'redirecting {entity_id} from {native_backend} ' + f'backend to {tr_backend}') + logger.info(msg) + context.target_backend = tr_backend + + def backend_by_entityid(self, context:Context): + entity_id = self._get_request_entity_id(context) + + if entity_id: + self._rewrite_context(entity_id, context) + else: + raise CustomRoutingError( + f"{self.__class__.__name__} " + "can't find any valid entity_id in the context." + ) + + if not context.state.get('ROUTER'): + raise SATOSAStateError( + f"{self.__class__.__name__} " + "can't find any valid state in the context." + ) + + data_serialized = context.state.get(self.name, {}).get("internal", {}) + data = InternalData.from_dict(data_serialized) + return super().process(context, data) + + class DecideBackendByRequester(RequestMicroService): """ Select which backend should be used based on who the requester is. diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 7a5227250..81425872d 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -3,9 +3,11 @@ import pytest from satosa.context import Context -from satosa.exception import SATOSAError, SATOSAConfigurationError +from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed +from satosa.micro_services.custom_routing import DecideBackendByTargetIdP +from satosa.micro_services.custom_routing import CustomRoutingError TARGET_ENTITY = "entity1" @@ -156,3 +158,63 @@ def test_missing_target_entity_id_from_context(self, context): req = InternalData(requester="test_requester") with pytest.raises(SATOSAError): decide_service.process(context, req) + + +class TestDecideBackendByTargetIdP: + rules = { + 'default_backend': 'Saml2', + 'endpoint_paths': ['.*/disco'], + 'target_mapping': {'http://idpspid.testunical.it:8088': 'spidSaml2'} + } + + def create_decide_service(self, rules): + decide_service = DecideBackendByTargetIdP( + config=rules, + name="test_decide_service", + base_url="https://satosa.example.com" + ) + decide_service.next = lambda ctx, data: data + return decide_service + + + def test_missing_state(self, target_context): + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'http://idpspid.testunical.it:8088', + } + req = InternalData(requester="test_requester") + req.requester = "somebody else" + assert decide_service.process(target_context, req) + + with pytest.raises(SATOSAStateError): + decide_service.backend_by_entityid(target_context) + + + def test_unmatching_target(self, target_context): + """ + It would rely on the default backend + """ + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'unknow-entity-id', + } + target_context.state['ROUTER'] = 'Saml2' + + req = InternalData(requester="test_requester") + assert decide_service.process(target_context, req) + + res = decide_service.backend_by_entityid(target_context) + assert isinstance(res, InternalData) + + def test_matching_target(self, target_context): + decide_service = self.create_decide_service(self.rules) + target_context.request = { + 'entityID': 'http://idpspid.testunical.it:8088-entity-id' + } + target_context.state['ROUTER'] = 'Saml2' + + req = InternalData(requester="test_requester") + req.requester = "somebody else" + assert decide_service.process(target_context, req) + res = decide_service.backend_by_entityid(target_context) + assert isinstance(res, InternalData) From e7ad982cfa7318f66f9bb45fe48a104ae4589789 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 14 Jul 2021 01:14:47 +0300 Subject: [PATCH 2/4] Fix DecideBackendByTargetIdP and introduce DecideBackendByDiscoIdP Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/custom_routing.py | 108 ++++++-------- .../micro_services/test_custom_routing.py | 133 +++++++++++------- 2 files changed, 130 insertions(+), 111 deletions(-) diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index 1eaccea5d..a276184c5 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -22,22 +22,55 @@ class CustomRoutingError(SATOSAError): class DecideBackendByTargetIdP(RequestMicroService): """ - Select which backend should be used based on who is the SAML IDP + Select target backend based on the target issuer. """ def __init__(self, config:dict, *args, **kwargs): """ Constructor. + :param config: microservice configuration loaded from yaml file :type config: Dict[str, Dict[str, str]] """ super().__init__(*args, **kwargs) + self.target_mapping = config['target_mapping'] - self.endpoint_paths = config['endpoint_paths'] self.default_backend = config['default_backend'] - if not isinstance(self.endpoint_paths, list): - raise SATOSAConfigurationError() + def process(self, context:Context, data:InternalData): + """ + Set context.target_backend based on the target issuer (context.target_entity_id) + + :param context: request context + :param data: the internal request + """ + target_issuer = context.get_decoration(Context.KEY_TARGET_ENTITYID) + if not target_issuer: + return super().process(context, data) + + target_backend = ( + self.target_mapping.get(target_issuer) + or self.default_backend + ) + + report = { + 'msg': 'decided target backend by target issuer', + 'target_issuer': target_issuer, + 'target_backend': target_backend, + } + logger.info(report) + + context.target_backend = target_backend + return super().process(context, data) + + +class DecideBackendByDiscoIdP(DecideBackendByTargetIdP): + def __init__(self, config:dict, *args, **kwargs): + super().__init__(config, *args, **kwargs) + + self.disco_endpoints = config['disco_endpoints'] + if not isinstance(self.disco_endpoints, list): + raise CustomRoutingError('disco_endpoints must be a list of str') def register_endpoints(self): """ @@ -54,69 +87,20 @@ def register_endpoints(self): [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] """ - # this intercepts disco response return [ - (path , self.backend_by_entityid) - for path in self.endpoint_paths + (path , self._handle_disco_response) + for path in self.disco_endpoints ] - def _get_request_entity_id(self, context): - return ( - context.get_decoration(Context.KEY_TARGET_ENTITYID) or - context.request.get('entityID') - ) - - def _get_backend(self, context:Context, entity_id:str) -> str: - """ - returns the Target Backend to use - """ - return ( - self.target_mapping.get(entity_id) or - self.default_backend - ) - - def process(self, context:Context, data:dict): - """ - Will modify the context.target_backend attribute based on the target entityid. - :param context: request context - :param data: the internal request - """ - entity_id = self._get_request_entity_id(context) - if entity_id: - self._rewrite_context(entity_id, context) - return super().process(context, data) - - def _rewrite_context(self, entity_id:str, context:Context) -> None: - tr_backend = self._get_backend(context, entity_id) - context.decorate(Context.KEY_TARGET_ENTITYID, entity_id) - context.target_frontend = context.target_frontend or context.state.get('ROUTER') - native_backend = context.target_backend - msg = (f'Found DecideBackendByTarget ({self.name} microservice) ' - f'redirecting {entity_id} from {native_backend} ' - f'backend to {tr_backend}') - logger.info(msg) - context.target_backend = tr_backend - - def backend_by_entityid(self, context:Context): - entity_id = self._get_request_entity_id(context) - - if entity_id: - self._rewrite_context(entity_id, context) - else: - raise CustomRoutingError( - f"{self.__class__.__name__} " - "can't find any valid entity_id in the context." - ) - - if not context.state.get('ROUTER'): - raise SATOSAStateError( - f"{self.__class__.__name__} " - "can't find any valid state in the context." - ) + def _handle_disco_response(self, context:Context): + target_issuer_from_disco = context.request.get('entityID') + if not target_issuer_from_disco: + raise CustomRoutingError('no valid entity_id in the disco response') - data_serialized = context.state.get(self.name, {}).get("internal", {}) + context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer_from_disco) + data_serialized = context.state.get(self.name, {}).get('internal', {}) data = InternalData.from_dict(data_serialized) - return super().process(context, data) + return self.process(context, data) class DecideBackendByRequester(RequestMicroService): diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 81425872d..9cbe4eda4 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -1,14 +1,18 @@ from base64 import urlsafe_b64encode +from unittest import TestCase import pytest from satosa.context import Context +from satosa.state import State from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed +from satosa.micro_services.custom_routing import DecideBackendByDiscoIdP from satosa.micro_services.custom_routing import DecideBackendByTargetIdP from satosa.micro_services.custom_routing import CustomRoutingError + TARGET_ENTITY = "entity1" @@ -160,61 +164,92 @@ def test_missing_target_entity_id_from_context(self, context): decide_service.process(context, req) -class TestDecideBackendByTargetIdP: - rules = { - 'default_backend': 'Saml2', - 'endpoint_paths': ['.*/disco'], - 'target_mapping': {'http://idpspid.testunical.it:8088': 'spidSaml2'} - } - - def create_decide_service(self, rules): - decide_service = DecideBackendByTargetIdP( - config=rules, - name="test_decide_service", - base_url="https://satosa.example.com" - ) - decide_service.next = lambda ctx, data: data - return decide_service +class TestDecideBackendByTargetIdP(TestCase): + def setUp(self): + context = Context() + context.state = State() - - def test_missing_state(self, target_context): - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'http://idpspid.testunical.it:8088', + config = { + 'default_backend': 'default_backend', + 'target_mapping': { + 'mapped_idp.example.org': 'mapped_backend', + }, + 'disco_endpoints': [ + '.*/disco', + ], } - req = InternalData(requester="test_requester") - req.requester = "somebody else" - assert decide_service.process(target_context, req) - - with pytest.raises(SATOSAStateError): - decide_service.backend_by_entityid(target_context) - - def test_unmatching_target(self, target_context): - """ - It would rely on the default backend - """ - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'unknow-entity-id', + plugin = DecideBackendByTargetIdP( + config=config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_when_target_is_not_set_do_skip(self): + data = InternalData(requester='test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert not newctx.target_backend + + def test_when_target_is_not_mapped_choose_default_backend(self): + self.context.decorate(Context.KEY_TARGET_ENTITYID, 'idp.example.org') + data = InternalData(requester='test_requester') + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'default_backend' + + def test_when_target_is_mapped_choose_mapping_backend(self): + self.context.decorate(Context.KEY_TARGET_ENTITYID, 'mapped_idp.example.org') + data = InternalData(requester='test_requester') + data.requester = 'somebody else' + newctx, newdata = self.plugin.process(self.context, data) + assert newctx.target_backend == 'mapped_backend' + + +class TestDecideBackendByDiscoIdP(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'default_backend': 'default_backend', + 'target_mapping': { + 'mapped_idp.example.org': 'mapped_backend', + }, + 'disco_endpoints': [ + '.*/disco', + ], } - target_context.state['ROUTER'] = 'Saml2' - req = InternalData(requester="test_requester") - assert decide_service.process(target_context, req) + plugin = DecideBackendByDiscoIdP( + config=config, + name='test_decide_service', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) - res = decide_service.backend_by_entityid(target_context) - assert isinstance(res, InternalData) + self.config = config + self.context = context + self.plugin = plugin - def test_matching_target(self, target_context): - decide_service = self.create_decide_service(self.rules) - target_context.request = { - 'entityID': 'http://idpspid.testunical.it:8088-entity-id' + def test_when_target_is_not_set_raise_error(self): + self.context.request = {} + with pytest.raises(CustomRoutingError): + self.plugin._handle_disco_response(self.context) + + def test_when_target_is_not_mapped_choose_default_backend(self): + self.context.request = { + 'entityID': 'idp.example.org', } - target_context.state['ROUTER'] = 'Saml2' + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.target_backend == 'default_backend' - req = InternalData(requester="test_requester") - req.requester = "somebody else" - assert decide_service.process(target_context, req) - res = decide_service.backend_by_entityid(target_context) - assert isinstance(res, InternalData) + def test_when_target_is_mapped_choose_mapping_backend(self): + self.context.request = { + 'entityID': 'mapped_idp.example.org', + } + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.target_backend == 'mapped_backend' From c0265f26b6b1aa8b0bb83d7f01f1becdf162f129 Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Wed, 14 Jul 2021 02:03:56 +0300 Subject: [PATCH 3/4] Separate disco handling from backend decision Signed-off-by: Ivan Kanakarakis --- .../disco_to_target_issuer.yaml.example | 6 ++ .../target_based_routing.yaml.example | 6 +- src/satosa/micro_services/custom_routing.py | 53 ++---------------- src/satosa/micro_services/disco.py | 48 ++++++++++++++++ .../micro_services/test_custom_routing.py | 56 +------------------ tests/satosa/micro_services/test_disco.py | 44 +++++++++++++++ 6 files changed, 106 insertions(+), 107 deletions(-) create mode 100644 example/plugins/microservices/disco_to_target_issuer.yaml.example create mode 100644 src/satosa/micro_services/disco.py create mode 100644 tests/satosa/micro_services/test_disco.py diff --git a/example/plugins/microservices/disco_to_target_issuer.yaml.example b/example/plugins/microservices/disco_to_target_issuer.yaml.example new file mode 100644 index 000000000..5d5d0100c --- /dev/null +++ b/example/plugins/microservices/disco_to_target_issuer.yaml.example @@ -0,0 +1,6 @@ +module: satosa.micro_services.disco.DiscoToTargetIssuer +name: DiscoToTargetIssuer +config: + # the regex that will intercept http requests to be handled with this microservice + disco_endpoints: + - ".*/disco" diff --git a/example/plugins/microservices/target_based_routing.yaml.example b/example/plugins/microservices/target_based_routing.yaml.example index 7b017dba8..55e699c53 100644 --- a/example/plugins/microservices/target_based_routing.yaml.example +++ b/example/plugins/microservices/target_based_routing.yaml.example @@ -1,12 +1,8 @@ -module: satosa.micro_services.custom_routing.DecideBackendByTargetIdP +module: satosa.micro_services.custom_routing.DecideBackendByTargetIssuer name: TargetRouter config: default_backend: Saml2 - # the regex that will intercept http requests to be handled with this microservice - endpoint_paths: - - ".*/disco" - target_mapping: "http://idpspid.testunical.it:8088": "spidSaml2" # map SAML entity with entity id 'target_id' to backend name "http://eidas.testunical.it:8081/saml2/metadata": "eidasSaml2" diff --git a/src/satosa/micro_services/custom_routing.py b/src/satosa/micro_services/custom_routing.py index a276184c5..541b824f1 100644 --- a/src/satosa/micro_services/custom_routing.py +++ b/src/satosa/micro_services/custom_routing.py @@ -7,20 +7,17 @@ from .base import RequestMicroService from ..exception import SATOSAConfigurationError from ..exception import SATOSAError -from ..exception import SATOSAStateError logger = logging.getLogger(__name__) class CustomRoutingError(SATOSAError): - """ - SATOSA exception raised by CustomRouting rules - """ + """SATOSA exception raised by CustomRouting rules""" pass -class DecideBackendByTargetIdP(RequestMicroService): +class DecideBackendByTargetIssuer(RequestMicroService): """ Select target backend based on the target issuer. """ @@ -38,14 +35,11 @@ def __init__(self, config:dict, *args, **kwargs): self.default_backend = config['default_backend'] def process(self, context:Context, data:InternalData): - """ - Set context.target_backend based on the target issuer (context.target_entity_id) + """Set context.target_backend based on the target issuer""" - :param context: request context - :param data: the internal request - """ target_issuer = context.get_decoration(Context.KEY_TARGET_ENTITYID) if not target_issuer: + logger.info('skipping backend decision because no target_issuer was found') return super().process(context, data) target_backend = ( @@ -64,45 +58,6 @@ def process(self, context:Context, data:InternalData): return super().process(context, data) -class DecideBackendByDiscoIdP(DecideBackendByTargetIdP): - def __init__(self, config:dict, *args, **kwargs): - super().__init__(config, *args, **kwargs) - - self.disco_endpoints = config['disco_endpoints'] - if not isinstance(self.disco_endpoints, list): - raise CustomRoutingError('disco_endpoints must be a list of str') - - def register_endpoints(self): - """ - URL mapping of additional endpoints this micro service needs to register for callbacks. - - Example of a mapping from the url path '/callback' to the callback() method of a micro service: - reg_endp = [ - ("^/callback1$", self.callback), - ] - - :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] - - :return: A list with functions and args bound to a specific endpoint url, - [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] - """ - - return [ - (path , self._handle_disco_response) - for path in self.disco_endpoints - ] - - def _handle_disco_response(self, context:Context): - target_issuer_from_disco = context.request.get('entityID') - if not target_issuer_from_disco: - raise CustomRoutingError('no valid entity_id in the disco response') - - context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer_from_disco) - data_serialized = context.state.get(self.name, {}).get('internal', {}) - data = InternalData.from_dict(data_serialized) - return self.process(context, data) - - class DecideBackendByRequester(RequestMicroService): """ Select which backend should be used based on who the requester is. diff --git a/src/satosa/micro_services/disco.py b/src/satosa/micro_services/disco.py new file mode 100644 index 000000000..7ea5bbe0a --- /dev/null +++ b/src/satosa/micro_services/disco.py @@ -0,0 +1,48 @@ +from satosa.context import Context +from satosa.internal import InternalData + +from .base import RequestMicroService +from ..exception import SATOSAError + + +class DiscoToTargetIssuerError(SATOSAError): + """SATOSA exception raised by CustomRouting rules""" + + +class DiscoToTargetIssuer(RequestMicroService): + def __init__(self, config:dict, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.disco_endpoints = config['disco_endpoints'] + if not isinstance(self.disco_endpoints, list) or not self.disco_endpoints: + raise DiscoToTargetIssuerError('disco_endpoints must be a list of str') + + def register_endpoints(self): + """ + URL mapping of additional endpoints this micro service needs to register for callbacks. + + Example of a mapping from the url path '/callback' to the callback() method of a micro service: + reg_endp = [ + ('^/callback1$', self.callback), + ] + + :rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]] + + :return: A list with functions and args bound to a specific endpoint url, + [(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...] + """ + + return [ + (path , self._handle_disco_response) + for path in self.disco_endpoints + ] + + def _handle_disco_response(self, context:Context): + target_issuer = context.request.get('entityID') + if not target_issuer: + raise DiscoToTargetIssuerError('no valid entity_id in the disco response') + + data_serialized = context.state.get(self.name, {}).get('internal_data', {}) + data = InternalData.from_dict(data_serialized) + context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer) + return super().process(context, data) diff --git a/tests/satosa/micro_services/test_custom_routing.py b/tests/satosa/micro_services/test_custom_routing.py index 9cbe4eda4..d2022bc3e 100644 --- a/tests/satosa/micro_services/test_custom_routing.py +++ b/tests/satosa/micro_services/test_custom_routing.py @@ -8,8 +8,7 @@ from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError from satosa.internal import InternalData from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed -from satosa.micro_services.custom_routing import DecideBackendByDiscoIdP -from satosa.micro_services.custom_routing import DecideBackendByTargetIdP +from satosa.micro_services.custom_routing import DecideBackendByTargetIssuer from satosa.micro_services.custom_routing import CustomRoutingError @@ -164,7 +163,7 @@ def test_missing_target_entity_id_from_context(self, context): decide_service.process(context, req) -class TestDecideBackendByTargetIdP(TestCase): +class TestDecideBackendByTargetIssuer(TestCase): def setUp(self): context = Context() context.state = State() @@ -174,12 +173,9 @@ def setUp(self): 'target_mapping': { 'mapped_idp.example.org': 'mapped_backend', }, - 'disco_endpoints': [ - '.*/disco', - ], } - plugin = DecideBackendByTargetIdP( + plugin = DecideBackendByTargetIssuer( config=config, name='test_decide_service', base_url='https://satosa.example.org', @@ -207,49 +203,3 @@ def test_when_target_is_mapped_choose_mapping_backend(self): data.requester = 'somebody else' newctx, newdata = self.plugin.process(self.context, data) assert newctx.target_backend == 'mapped_backend' - - -class TestDecideBackendByDiscoIdP(TestCase): - def setUp(self): - context = Context() - context.state = State() - - config = { - 'default_backend': 'default_backend', - 'target_mapping': { - 'mapped_idp.example.org': 'mapped_backend', - }, - 'disco_endpoints': [ - '.*/disco', - ], - } - - plugin = DecideBackendByDiscoIdP( - config=config, - name='test_decide_service', - base_url='https://satosa.example.org', - ) - plugin.next = lambda ctx, data: (ctx, data) - - self.config = config - self.context = context - self.plugin = plugin - - def test_when_target_is_not_set_raise_error(self): - self.context.request = {} - with pytest.raises(CustomRoutingError): - self.plugin._handle_disco_response(self.context) - - def test_when_target_is_not_mapped_choose_default_backend(self): - self.context.request = { - 'entityID': 'idp.example.org', - } - newctx, newdata = self.plugin._handle_disco_response(self.context) - assert newctx.target_backend == 'default_backend' - - def test_when_target_is_mapped_choose_mapping_backend(self): - self.context.request = { - 'entityID': 'mapped_idp.example.org', - } - newctx, newdata = self.plugin._handle_disco_response(self.context) - assert newctx.target_backend == 'mapped_backend' diff --git a/tests/satosa/micro_services/test_disco.py b/tests/satosa/micro_services/test_disco.py new file mode 100644 index 000000000..ac2c3c5c2 --- /dev/null +++ b/tests/satosa/micro_services/test_disco.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +import pytest + +from satosa.context import Context +from satosa.state import State +from satosa.micro_services.disco import DiscoToTargetIssuer +from satosa.micro_services.disco import DiscoToTargetIssuerError + + +class TestDiscoToTargetIssuer(TestCase): + def setUp(self): + context = Context() + context.state = State() + + config = { + 'disco_endpoints': [ + '.*/disco', + ], + } + + plugin = DiscoToTargetIssuer( + config=config, + name='test_disco_to_target_issuer', + base_url='https://satosa.example.org', + ) + plugin.next = lambda ctx, data: (ctx, data) + + self.config = config + self.context = context + self.plugin = plugin + + def test_when_entity_id_is_not_set_raise_error(self): + self.context.request = {} + with pytest.raises(DiscoToTargetIssuerError): + self.plugin._handle_disco_response(self.context) + + def test_when_entity_id_is_set_target_issuer_is_set(self): + entity_id = 'idp.example.org' + self.context.request = { + 'entityID': entity_id, + } + newctx, newdata = self.plugin._handle_disco_response(self.context) + assert newctx.get_decoration(Context.KEY_TARGET_ENTITYID) == entity_id From d7e45721d94ecc86501ab7eed46a20b41842501b Mon Sep 17 00:00:00 2001 From: Ivan Kanakarakis Date: Fri, 16 Jul 2021 22:04:53 +0300 Subject: [PATCH 4/4] Set target_frontend after handling the disco response When the processing of the request micro-services is finished, the context is switched from the frontend to the backend. At that point target_frontend is needed to set the state of the router. The router state will be used when the processing of the response by the response micro-services is finished, to find the appropriate frontend instance. --- The routing state is set at the point when the switch from the frontend (and request micro-service processing) is made towards the backend. If the discovery response was not intercepted by the DiscoToTargetIssuer micro-service, and instead was processed by the backend's disco-response handler, the target_frontend would not be needed, as the routing state would have already been set. When the DiscoToTargetIssuer micro-service intercepts the response, the point when the switch from the frontend to the backend happens will be executed again. Due to leaving the proxy, going to the discovery service and coming back to the proxy, context.target_frontend has been lost. Only the state stored within context.state persists (through the cookie). --- When the request micro-services finish processing the request, backend_routing is called, which sets the router state (context.state['ROUTER']) to target_frontend, and returns the appropriate backend instance based on target_backend. When the time comes to switch from the backend to the frontend, that state is looked up (see below). When the response micro-services finish processing the response, frontend_routing is called, which sets target_frontend from the router state (context.state['ROUTER']) and returns the appropriate frontend instance based on target_frontend. --- Signed-off-by: Ivan Kanakarakis --- src/satosa/micro_services/disco.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/satosa/micro_services/disco.py b/src/satosa/micro_services/disco.py index 7ea5bbe0a..274f18780 100644 --- a/src/satosa/micro_services/disco.py +++ b/src/satosa/micro_services/disco.py @@ -17,6 +17,13 @@ def __init__(self, config:dict, *args, **kwargs): if not isinstance(self.disco_endpoints, list) or not self.disco_endpoints: raise DiscoToTargetIssuerError('disco_endpoints must be a list of str') + def process(self, context:Context, data:InternalData): + context.state[self.name] = { + 'target_frontend': context.target_frontend, + 'internal_data': data.to_dict(), + } + return super().process(context, data) + def register_endpoints(self): """ URL mapping of additional endpoints this micro service needs to register for callbacks. @@ -42,7 +49,10 @@ def _handle_disco_response(self, context:Context): if not target_issuer: raise DiscoToTargetIssuerError('no valid entity_id in the disco response') + target_frontend = context.state.get(self.name, {}).get('target_frontend') data_serialized = context.state.get(self.name, {}).get('internal_data', {}) data = InternalData.from_dict(data_serialized) + + context.target_frontend = target_frontend context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer) return super().process(context, data)