From 3b3a9cfa43d4a289462512b49ed520bd86fbbb31 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 23 May 2017 15:15:24 +0200 Subject: [PATCH 1/8] initial but somewhat tested --- .../attribute_generation.yaml.example | 11 +++ setup.py | 3 +- .../micro_services/attribute_generation.py | 78 +++++++++++++++++++ .../test_attribute_generation.py | 64 +++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 example/plugins/microservices/attribute_generation.yaml.example create mode 100644 src/satosa/micro_services/attribute_generation.py create mode 100644 tests/satosa/micro_services/test_attribute_generation.py diff --git a/example/plugins/microservices/attribute_generation.yaml.example b/example/plugins/microservices/attribute_generation.yaml.example new file mode 100644 index 000000000..44b5e208b --- /dev/null +++ b/example/plugins/microservices/attribute_generation.yaml.example @@ -0,0 +1,11 @@ +module: satosa.micro_services.attribute_generation.AddSyntheticAttributes +name: AddSyntheticAttributes +config: + synthetic_attributes: + target_provider1: + requester1: + eduPersonAffiliation: member;employee + default: + default: + schacHomeOrganization: {{eduPersonPrincipalName.scope}} + schacHomeOrganizationType: tomfoolery provider diff --git a/setup.py b/setup.py index 2fa4213ef..38427d309 100644 --- a/setup.py +++ b/setup.py @@ -22,10 +22,11 @@ "PyYAML", "gunicorn", "Werkzeug", - "click" + "click", ], extras_require={ "ldap": ["ldap3"], + "pystache": ["pystache"] }, zip_safe=False, classifiers=[ diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py new file mode 100644 index 000000000..b67fbe30d --- /dev/null +++ b/src/satosa/micro_services/attribute_generation.py @@ -0,0 +1,78 @@ +import re +import pystache + +from .base import ResponseMicroService + +def _config(f, requester, provider): + pf = f.get(provider, f.get("", f.get("default", {}))) + rf = pf.get(requester, pf.get("", pf.get("default", {}))) + return rf.items() + +class MustachAttrValue(object): + def __init__(self,values): + self.values = values + if any(['@' in v for v in values]): + local_parts = [] + domain_parts = [] + scopes = dict() + for v in values: + (local_part, sep, domain_part) = v.partition('@') + # probably not needed now... + local_parts.append(local_part) + domain_parts.append(domain_part) + scopes[domain_part] = True + self._scopes = list(scopes.keys()) + else: + self._scopes = None + + def __str__(self): + return ";".join(self.values) + + @property + def value(self): + if 1 == len(self.values): + return self.values[0] + else: + return self.values + + @property + def first(self): + if len(self.values) > 0: + return self.values[0] + else: + return "" + + @property + def scope(self): + if self._scopes is not None: + return self._scopes[0] + return "" + + +class AddSyntheticAttributes(ResponseMicroService): + """ + Add synthetic attributes to the responses. + """ + + def __init__(self, config, *args, **kwargs): + super().__init__(*args, **kwargs) + self.synthetic_attributes = config["synthetic_attributes"] + + def _synthesize(self, attributes, requester, provider): + syn_attributes = dict() + context = dict() + + for attr_name,values in attributes.items(): + context[attr_name] = MustachAttrValue(values) + + recipes = _config(self.synthetic_attributes, requester, provider) + print(context) + for attr_name, fmt in recipes: + print(fmt) + syn_attributes[attr_name] = re.split("[;\n]+", pystache.render(fmt, context)) + print(syn_attributes) + return syn_attributes + + def process(self, context, data): + data.attributes.update(self._synthesize(data.attributes, data.requester, data.auth_info.issuer)) + return super().process(context, data) diff --git a/tests/satosa/micro_services/test_attribute_generation.py b/tests/satosa/micro_services/test_attribute_generation.py new file mode 100644 index 000000000..9379f3fdf --- /dev/null +++ b/tests/satosa/micro_services/test_attribute_generation.py @@ -0,0 +1,64 @@ +from satosa.internal_data import InternalResponse, AuthenticationInformation +from satosa.micro_services.attribute_generation import AddSyntheticAttributes +from satosa.exception import SATOSAAuthenticationError +from satosa.context import Context + +class TestAddSyntheticAttributes: + def create_syn_service(self, synthetic_attributes): + authz_service = AddSyntheticAttributes(config=dict(synthetic_attributes=synthetic_attributes), + name="test_gen", + base_url="https://satosa.example.com") + authz_service.next = lambda ctx, data: data + return authz_service + + def test_generate_static(self): + synthetic_attributes = { + "": { "default": {"a0": "value1;value2" }} + } + authz_service = self.create_syn_service(synthetic_attributes) + resp = InternalResponse(AuthenticationInformation(None, None, None)) + resp.attributes = { + "a1": ["test@example.com"], + } + ctx = Context() + ctx.state = dict() + authz_service.process(ctx, resp) + assert("value1" in resp.attributes['a0']) + assert("value2" in resp.attributes['a0']) + assert("test@example.com" in resp.attributes['a1']) + + def test_generate_mustache1(self): + synthetic_attributes = { + "": { "default": {"a0": "{{kaka}}#{{eppn.scope}}" }} + } + authz_service = self.create_syn_service(synthetic_attributes) + resp = InternalResponse(AuthenticationInformation(None, None, None)) + resp.attributes = { + "kaka": ["kaka1"], + "eppn": ["a@example.com","b@example.com"] + } + ctx = Context() + ctx.state = dict() + authz_service.process(ctx, resp) + assert("kaka1#example.com" in resp.attributes['a0']) + assert("kaka1" in resp.attributes['kaka']) + assert("a@example.com" in resp.attributes['eppn']) + assert("b@example.com" in resp.attributes['eppn']) + + def test_generate_mustache2(self): + synthetic_attributes = { + "": { "default": {"a0": "{{kaka.first}}#{{eppn.scope}}" }} + } + authz_service = self.create_syn_service(synthetic_attributes) + resp = InternalResponse(AuthenticationInformation(None, None, None)) + resp.attributes = { + "kaka": ["kaka1","kaka2"], + "eppn": ["a@example.com","b@example.com"] + } + ctx = Context() + ctx.state = dict() + authz_service.process(ctx, resp) + assert("kaka1#example.com" in resp.attributes['a0']) + assert("kaka1" in resp.attributes['kaka']) + assert("a@example.com" in resp.attributes['eppn']) + assert("b@example.com" in resp.attributes['eppn']) From 4ec911b799ca330f2c3c486b604d90855a78e1e8 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 23 May 2017 15:46:09 +0200 Subject: [PATCH 2/8] keep track of attribute_name for mustach iterators --- .../micro_services/attribute_generation.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index b67fbe30d..0a23a3a58 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -9,8 +9,9 @@ def _config(f, requester, provider): return rf.items() class MustachAttrValue(object): - def __init__(self,values): - self.values = values + def __init__(self, attr_name, values): + self._attr_name = attr_name + self._values = values if any(['@' in v for v in values]): local_parts = [] domain_parts = [] @@ -26,19 +27,23 @@ def __init__(self,values): self._scopes = None def __str__(self): - return ";".join(self.values) + return ";".join(self._values) + + @property + def values(self): + [{self._attr_name: v} for v in self._values] @property def value(self): - if 1 == len(self.values): - return self.values[0] + if 1 == len(self._values): + return self._values[0] else: - return self.values + return self._values @property def first(self): - if len(self.values) > 0: - return self.values[0] + if len(self._values) > 0: + return self._values[0] else: return "" @@ -63,13 +68,13 @@ def _synthesize(self, attributes, requester, provider): context = dict() for attr_name,values in attributes.items(): - context[attr_name] = MustachAttrValue(values) + context[attr_name] = MustachAttrValue(attr_name, values) recipes = _config(self.synthetic_attributes, requester, provider) print(context) for attr_name, fmt in recipes: print(fmt) - syn_attributes[attr_name] = re.split("[;\n]+", pystache.render(fmt, context)) + syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))] print(syn_attributes) return syn_attributes From 6ec37c59a3327747f483f4365cea28906f9ccb95 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 23 May 2017 15:46:16 +0200 Subject: [PATCH 3/8] docs --- .../micro_services/attribute_generation.py | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 0a23a3a58..c5e28d99e 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -56,7 +56,69 @@ def scope(self): class AddSyntheticAttributes(ResponseMicroService): """ - Add synthetic attributes to the responses. +A class that add generated or synthetic attributes to a response set. Attribute +generation is done using mustach (http://mustache.github.io) templates. The +following example configuration illustrates most common features: + +```yaml +module: satosa.micro_services.attribute_generation.AddSyntheticAttributes +name: AddSyntheticAttributes +config: + synthetic_attributes: + target_provider1: + requester1: + eduPersonAffiliation: member;employee + default: + default: + schacHomeOrganization: {{eduPersonPrincipalName.scope}} + schacHomeOrganizationType: tomfoolery provider + +``` + +The use of "" and 'default' is synonymous. Attribute rules are not +overloaded or inherited. For instance a response from "target_provider1" +and requester1 in the above config will generate a (static) attribute +set of 'member' and 'employee' for the eduPersonAffiliation attribute +and nothing else. Note that synthetic attributes override existing +attributes if present. + +*Evaluating and interpreting templates* + +Attribute values are split on combinations of ';' and newline so that +a template resulting in the following text: +``` +a; +b;c +``` +results in three attribute values: 'a','b' and 'c'. Templates are +evaluated with a single context that represents the response attributes +before the microservice is processed. De-referencing the attribute +name as in '{{name}}' results in a ';'-separated list of all attribute +values. This notation is useful when you know there is only a single +attribute value in the set. + +*Special contexts* + +For treating the values as a list - eg for interating using mustach, +use the .values sub-context For instance to synthesize all fist-last +name combinations do this: + +``` +{{#givenName.values}} + {{#sn.values}}{{givenName}} {{sn}}{{/sn.values}} +{{/givenName.values}} +``` + +Note that the .values sub-context behaves as if it is an iterator +over single-value context with the same key name as the original +attribute name. + +The .scope sub-context evalues to the right-hand part of any @ +sign. This is assumed to be single valued. + +The .first sub-context evalues to the first value of a context +which may be safer to use if the attribute is multivalued but +you don't care which value is used in a template. """ def __init__(self, config, *args, **kwargs): From f453b761305f94a1819ca07f8b888bc989f8e80f Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 30 May 2017 12:24:17 +0200 Subject: [PATCH 4/8] add pystache dependency where it needs to go --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 38427d309..0df8afe38 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,10 @@ "gunicorn", "Werkzeug", "click", + "pystache" ], extras_require={ - "ldap": ["ldap3"], - "pystache": ["pystache"] + "ldap": ["ldap3"] }, zip_safe=False, classifiers=[ From 7f762af7adaa803a0545e9294567bd12c609a887 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 30 May 2017 17:15:57 +0200 Subject: [PATCH 5/8] readability --- src/satosa/micro_services/attribute_generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index c5e28d99e..8e75517ab 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -35,7 +35,7 @@ def values(self): @property def value(self): - if 1 == len(self._values): + if len(self._values) == 1: return self._values[0] else: return self._values From 2823291486898e928637f8a23116f9a28124b26c Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 30 May 2017 17:16:28 +0200 Subject: [PATCH 6/8] nit --- src/satosa/micro_services/attribute_generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 8e75517ab..994f49edd 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -100,7 +100,7 @@ class AddSyntheticAttributes(ResponseMicroService): *Special contexts* For treating the values as a list - eg for interating using mustach, -use the .values sub-context For instance to synthesize all fist-last +use the .values sub-context For instance to synthesize all first-last name combinations do this: ``` From f772efd2667350a69c5db28ca2403c56cdfae438 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 30 May 2017 17:17:26 +0200 Subject: [PATCH 7/8] remove rogue prints --- src/satosa/micro_services/attribute_generation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index 994f49edd..ceaab823d 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -133,11 +133,8 @@ def _synthesize(self, attributes, requester, provider): context[attr_name] = MustachAttrValue(attr_name, values) recipes = _config(self.synthetic_attributes, requester, provider) - print(context) for attr_name, fmt in recipes: - print(fmt) syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))] - print(syn_attributes) return syn_attributes def process(self, context, data): From 407904c524cc434455fa884ab0eefc0f05891d18 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 30 May 2017 17:26:42 +0200 Subject: [PATCH 8/8] refactor --- src/satosa/micro_services/attribute_generation.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/satosa/micro_services/attribute_generation.py b/src/satosa/micro_services/attribute_generation.py index ceaab823d..57ee8dd08 100644 --- a/src/satosa/micro_services/attribute_generation.py +++ b/src/satosa/micro_services/attribute_generation.py @@ -2,11 +2,7 @@ import pystache from .base import ResponseMicroService - -def _config(f, requester, provider): - pf = f.get(provider, f.get("", f.get("default", {}))) - rf = pf.get(requester, pf.get("", pf.get("default", {}))) - return rf.items() +from ..util import get_dict_defaults class MustachAttrValue(object): def __init__(self, attr_name, values): @@ -132,8 +128,8 @@ def _synthesize(self, attributes, requester, provider): for attr_name,values in attributes.items(): context[attr_name] = MustachAttrValue(attr_name, values) - recipes = _config(self.synthetic_attributes, requester, provider) - for attr_name, fmt in recipes: + recipes = get_dict_defaults(self.synthetic_attributes, requester, provider) + for attr_name, fmt in recipes.items(): syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))] return syn_attributes