Skip to content

Commit 03afea4

Browse files
committed
feat: DecideBackedByTarget microservice
1 parent ba86be2 commit 03afea4

File tree

3 files changed

+176
-1
lines changed

3 files changed

+176
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module: satosa.micro_services.custom_routing.DecideBackendByTarget
2+
name: TargetRouter
3+
config:
4+
5+
# the regex that will intercept http requests to be handled with this microservice
6+
endpoint_path: ".*/disco"
7+
8+
target_mapping:
9+
"http://idpspid.testunical.it:8088": "spidSaml2" # map SAML entity with entity id 'target_id' to backend name
10+
"http://eidas.testunical.it:8081/saml2/metadata": "eidasSaml2"

src/satosa/micro_services/custom_routing.py

+89
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,103 @@
22
from base64 import urlsafe_b64encode
33

44
from satosa.context import Context
5+
from satosa.internal import InternalData
6+
57
from .base import RequestMicroService
68
from ..exception import SATOSAConfigurationError
79
from ..exception import SATOSAError
10+
from ..exception import SATOSAStateError
811

912

1013
logger = logging.getLogger(__name__)
1114

1215

16+
class CustomRoutingError(SATOSAError):
17+
"""
18+
SATOSA exception raised by CustomRouting rules
19+
"""
20+
pass
21+
22+
23+
class DecideBackendByTarget(RequestMicroService):
24+
"""
25+
Select which backend should be used based on who is the SAML IDP
26+
"""
27+
28+
def __init__(self, config, *args, **kwargs):
29+
"""
30+
Constructor.
31+
:param config: microservice configuration loaded from yaml file
32+
:type config: Dict[str, Dict[str, str]]
33+
"""
34+
super().__init__(*args, **kwargs)
35+
self.target_mapping = config['target_mapping']
36+
self.endpoint_paths = config['endpoint_paths']
37+
self.default_backend = config['default_backend']
38+
39+
if not isinstance(self.endpoint_paths, list):
40+
raise SATOSAConfigurationError()
41+
42+
def register_endpoints(self):
43+
"""
44+
URL mapping of additional endpoints this micro service needs to register for callbacks.
45+
46+
Example of a mapping from the url path '/callback' to the callback() method of a micro service:
47+
reg_endp = [
48+
("^/callback1$", self.callback),
49+
]
50+
51+
:rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]]
52+
53+
:return: A list with functions and args bound to a specific endpoint url,
54+
[(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...]
55+
"""
56+
57+
# this intercepts disco response
58+
return [
59+
(path , self.backend_by_entityid)
60+
for path in self.endpoint_paths
61+
]
62+
63+
def _get_backend(self, context:str, entity_id:str) -> str:
64+
"""
65+
returns the Target Backend to use
66+
"""
67+
return (
68+
self.target_mapping.get(entity_id) or
69+
self.default_backend
70+
)
71+
72+
def backend_by_entityid(self, context):
73+
entity_id = context.request.get('entityID')
74+
tr_backend = self._get_backend(context, entity_id)
75+
76+
if not context.state.get('ROUTER'):
77+
raise SATOSAStateError(
78+
f"{self.__class__.__name__} "
79+
"can't find any valid state in the context."
80+
)
81+
82+
if entity_id:
83+
context.internal_data['target_entity_id'] = entity_id
84+
context.target_frontend = context.state['ROUTER']
85+
native_backend = context.target_backend
86+
msg = (f'Found DecideBackendByTarget ({self.name} microservice) '
87+
f'redirecting {entity_id} from {native_backend} '
88+
f'backend to {tr_backend}')
89+
logger.info(msg)
90+
context.target_backend = tr_backend
91+
else:
92+
raise CustomRoutingError(
93+
f"{self.__class__.__name__} "
94+
"can't find any valid entity_id in the context."
95+
)
96+
97+
data_serialized = context.state.get(self.name, {}).get("internal", {})
98+
data = InternalData.from_dict(data_serialized)
99+
return super().process(context, data)
100+
101+
13102
class DecideBackendByRequester(RequestMicroService):
14103
"""
15104
Select which backend should be used based on who the requester is.

tests/satosa/micro_services/test_custom_routing.py

+77-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import pytest
44

55
from satosa.context import Context
6-
from satosa.exception import SATOSAError, SATOSAConfigurationError
6+
from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError
77
from satosa.internal import InternalData
88
from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed
9+
from satosa.micro_services.custom_routing import DecideBackendByTarget
10+
from satosa.micro_services.custom_routing import CustomRoutingError
911

1012
TARGET_ENTITY = "entity1"
1113

@@ -156,3 +158,77 @@ def test_missing_target_entity_id_from_context(self, context):
156158
req = InternalData(requester="test_requester")
157159
with pytest.raises(SATOSAError):
158160
decide_service.process(context, req)
161+
162+
163+
class TestDecideBackendByTarget:
164+
rules = {
165+
'default_backend': 'Saml2',
166+
'endpoint_paths': ['.*/disco'],
167+
'target_mapping': {'http://idpspid.testunical.it:8088': 'spidSaml2'}
168+
}
169+
170+
def create_decide_service(self, rules):
171+
decide_service = DecideBackendByTarget(
172+
config=rules,
173+
name="test_decide_service",
174+
base_url="https://satosa.example.com"
175+
)
176+
decide_service.next = lambda ctx, data: data
177+
return decide_service
178+
179+
180+
def test_missing_state(self, target_context):
181+
decide_service = self.create_decide_service(self.rules)
182+
target_context.request = {
183+
'entityID': 'http://idpspid.testunical.it:8088',
184+
}
185+
req = InternalData(requester="test_requester")
186+
req.requester = "somebody else"
187+
assert decide_service.process(target_context, req)
188+
189+
with pytest.raises(SATOSAStateError):
190+
decide_service.backend_by_entityid(target_context)
191+
192+
193+
def test_missing_entityid(self, target_context):
194+
decide_service = self.create_decide_service(self.rules)
195+
target_context.request = {
196+
# 'entityID': None,
197+
}
198+
target_context.state['ROUTER'] = 'Saml2'
199+
200+
req = InternalData(requester="test_requester")
201+
assert decide_service.process(target_context, req)
202+
203+
with pytest.raises(CustomRoutingError):
204+
decide_service.backend_by_entityid(target_context)
205+
206+
207+
def test_unmatching_target(self, target_context):
208+
"""
209+
It would rely on the default backend
210+
"""
211+
decide_service = self.create_decide_service(self.rules)
212+
target_context.request = {
213+
'entityID': 'unknow-entity-id',
214+
}
215+
target_context.state['ROUTER'] = 'Saml2'
216+
217+
req = InternalData(requester="test_requester")
218+
assert decide_service.process(target_context, req)
219+
220+
res = decide_service.backend_by_entityid(target_context)
221+
assert isinstance(res, InternalData)
222+
223+
def test_matching_target(self, target_context):
224+
decide_service = self.create_decide_service(self.rules)
225+
target_context.request = {
226+
'entityID': 'http://idpspid.testunical.it:8088-entity-id'
227+
}
228+
target_context.state['ROUTER'] = 'Saml2'
229+
230+
req = InternalData(requester="test_requester")
231+
req.requester = "somebody else"
232+
assert decide_service.process(target_context, req)
233+
res = decide_service.backend_by_entityid(target_context)
234+
assert isinstance(res, InternalData)

0 commit comments

Comments
 (0)