Skip to content

Commit 961dd99

Browse files
Merge pull request #220 from peppelinux/DecideBackendByTarget
[micro-service] Set target-issuer from disco response and decide backend by target-issuer
2 parents 674f825 + d7e4572 commit 961dd99

File tree

6 files changed

+212
-1
lines changed

6 files changed

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

src/satosa/micro_services/custom_routing.py

+48
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
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,6 +12,52 @@
1012
logger = logging.getLogger(__name__)
1113

1214

15+
class CustomRoutingError(SATOSAError):
16+
"""SATOSA exception raised by CustomRouting rules"""
17+
pass
18+
19+
20+
class DecideBackendByTargetIssuer(RequestMicroService):
21+
"""
22+
Select target backend based on the target issuer.
23+
"""
24+
25+
def __init__(self, config:dict, *args, **kwargs):
26+
"""
27+
Constructor.
28+
29+
:param config: microservice configuration loaded from yaml file
30+
:type config: Dict[str, Dict[str, str]]
31+
"""
32+
super().__init__(*args, **kwargs)
33+
34+
self.target_mapping = config['target_mapping']
35+
self.default_backend = config['default_backend']
36+
37+
def process(self, context:Context, data:InternalData):
38+
"""Set context.target_backend based on the target issuer"""
39+
40+
target_issuer = context.get_decoration(Context.KEY_TARGET_ENTITYID)
41+
if not target_issuer:
42+
logger.info('skipping backend decision because no target_issuer was found')
43+
return super().process(context, data)
44+
45+
target_backend = (
46+
self.target_mapping.get(target_issuer)
47+
or self.default_backend
48+
)
49+
50+
report = {
51+
'msg': 'decided target backend by target issuer',
52+
'target_issuer': target_issuer,
53+
'target_backend': target_backend,
54+
}
55+
logger.info(report)
56+
57+
context.target_backend = target_backend
58+
return super().process(context, data)
59+
60+
1361
class DecideBackendByRequester(RequestMicroService):
1462
"""
1563
Select which backend should be used based on who the requester is.

src/satosa/micro_services/disco.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from satosa.context import Context
2+
from satosa.internal import InternalData
3+
4+
from .base import RequestMicroService
5+
from ..exception import SATOSAError
6+
7+
8+
class DiscoToTargetIssuerError(SATOSAError):
9+
"""SATOSA exception raised by CustomRouting rules"""
10+
11+
12+
class DiscoToTargetIssuer(RequestMicroService):
13+
def __init__(self, config:dict, *args, **kwargs):
14+
super().__init__(*args, **kwargs)
15+
16+
self.disco_endpoints = config['disco_endpoints']
17+
if not isinstance(self.disco_endpoints, list) or not self.disco_endpoints:
18+
raise DiscoToTargetIssuerError('disco_endpoints must be a list of str')
19+
20+
def process(self, context:Context, data:InternalData):
21+
context.state[self.name] = {
22+
'target_frontend': context.target_frontend,
23+
'internal_data': data.to_dict(),
24+
}
25+
return super().process(context, data)
26+
27+
def register_endpoints(self):
28+
"""
29+
URL mapping of additional endpoints this micro service needs to register for callbacks.
30+
31+
Example of a mapping from the url path '/callback' to the callback() method of a micro service:
32+
reg_endp = [
33+
('^/callback1$', self.callback),
34+
]
35+
36+
:rtype List[Tuple[str, Callable[[satosa.context.Context, Any], satosa.response.Response]]]
37+
38+
:return: A list with functions and args bound to a specific endpoint url,
39+
[(regexp, Callable[[satosa.context.Context], satosa.response.Response]), ...]
40+
"""
41+
42+
return [
43+
(path , self._handle_disco_response)
44+
for path in self.disco_endpoints
45+
]
46+
47+
def _handle_disco_response(self, context:Context):
48+
target_issuer = context.request.get('entityID')
49+
if not target_issuer:
50+
raise DiscoToTargetIssuerError('no valid entity_id in the disco response')
51+
52+
target_frontend = context.state.get(self.name, {}).get('target_frontend')
53+
data_serialized = context.state.get(self.name, {}).get('internal_data', {})
54+
data = InternalData.from_dict(data_serialized)
55+
56+
context.target_frontend = target_frontend
57+
context.decorate(Context.KEY_TARGET_ENTITYID, target_issuer)
58+
return super().process(context, data)

tests/satosa/micro_services/test_custom_routing.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from base64 import urlsafe_b64encode
2+
from unittest import TestCase
23

34
import pytest
45

56
from satosa.context import Context
6-
from satosa.exception import SATOSAError, SATOSAConfigurationError
7+
from satosa.state import State
8+
from satosa.exception import SATOSAError, SATOSAConfigurationError, SATOSAStateError
79
from satosa.internal import InternalData
810
from satosa.micro_services.custom_routing import DecideIfRequesterIsAllowed
11+
from satosa.micro_services.custom_routing import DecideBackendByTargetIssuer
12+
from satosa.micro_services.custom_routing import CustomRoutingError
13+
914

1015
TARGET_ENTITY = "entity1"
1116

@@ -156,3 +161,45 @@ def test_missing_target_entity_id_from_context(self, context):
156161
req = InternalData(requester="test_requester")
157162
with pytest.raises(SATOSAError):
158163
decide_service.process(context, req)
164+
165+
166+
class TestDecideBackendByTargetIssuer(TestCase):
167+
def setUp(self):
168+
context = Context()
169+
context.state = State()
170+
171+
config = {
172+
'default_backend': 'default_backend',
173+
'target_mapping': {
174+
'mapped_idp.example.org': 'mapped_backend',
175+
},
176+
}
177+
178+
plugin = DecideBackendByTargetIssuer(
179+
config=config,
180+
name='test_decide_service',
181+
base_url='https://satosa.example.org',
182+
)
183+
plugin.next = lambda ctx, data: (ctx, data)
184+
185+
self.config = config
186+
self.context = context
187+
self.plugin = plugin
188+
189+
def test_when_target_is_not_set_do_skip(self):
190+
data = InternalData(requester='test_requester')
191+
newctx, newdata = self.plugin.process(self.context, data)
192+
assert not newctx.target_backend
193+
194+
def test_when_target_is_not_mapped_choose_default_backend(self):
195+
self.context.decorate(Context.KEY_TARGET_ENTITYID, 'idp.example.org')
196+
data = InternalData(requester='test_requester')
197+
newctx, newdata = self.plugin.process(self.context, data)
198+
assert newctx.target_backend == 'default_backend'
199+
200+
def test_when_target_is_mapped_choose_mapping_backend(self):
201+
self.context.decorate(Context.KEY_TARGET_ENTITYID, 'mapped_idp.example.org')
202+
data = InternalData(requester='test_requester')
203+
data.requester = 'somebody else'
204+
newctx, newdata = self.plugin.process(self.context, data)
205+
assert newctx.target_backend == 'mapped_backend'
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from unittest import TestCase
2+
3+
import pytest
4+
5+
from satosa.context import Context
6+
from satosa.state import State
7+
from satosa.micro_services.disco import DiscoToTargetIssuer
8+
from satosa.micro_services.disco import DiscoToTargetIssuerError
9+
10+
11+
class TestDiscoToTargetIssuer(TestCase):
12+
def setUp(self):
13+
context = Context()
14+
context.state = State()
15+
16+
config = {
17+
'disco_endpoints': [
18+
'.*/disco',
19+
],
20+
}
21+
22+
plugin = DiscoToTargetIssuer(
23+
config=config,
24+
name='test_disco_to_target_issuer',
25+
base_url='https://satosa.example.org',
26+
)
27+
plugin.next = lambda ctx, data: (ctx, data)
28+
29+
self.config = config
30+
self.context = context
31+
self.plugin = plugin
32+
33+
def test_when_entity_id_is_not_set_raise_error(self):
34+
self.context.request = {}
35+
with pytest.raises(DiscoToTargetIssuerError):
36+
self.plugin._handle_disco_response(self.context)
37+
38+
def test_when_entity_id_is_set_target_issuer_is_set(self):
39+
entity_id = 'idp.example.org'
40+
self.context.request = {
41+
'entityID': entity_id,
42+
}
43+
newctx, newdata = self.plugin._handle_disco_response(self.context)
44+
assert newctx.get_decoration(Context.KEY_TARGET_ENTITYID) == entity_id

0 commit comments

Comments
 (0)