Skip to content

Attribute generation #100

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 30, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions example/plugins/microservices/attribute_generation.yaml.example
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
"PyYAML",
"gunicorn",
"Werkzeug",
"click"
"click",
"pystache"
],
extras_require={
"ldap": ["ldap3"],
"ldap": ["ldap3"]
},
zip_safe=False,
classifiers=[
Expand Down
138 changes: 138 additions & 0 deletions src/satosa/micro_services/attribute_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import re
import pystache

from .base import ResponseMicroService
from ..util import get_dict_defaults

class MustachAttrValue(object):
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 = []
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 values(self):
[{self._attr_name: v} for v in self._values]

@property
def value(self):
if len(self._values) == 1:
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):
"""
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 first-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):
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(attr_name, values)

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

def process(self, context, data):
data.attributes.update(self._synthesize(data.attributes, data.requester, data.auth_info.issuer))
return super().process(context, data)
64 changes: 64 additions & 0 deletions tests/satosa/micro_services/test_attribute_generation.py
Original file line number Diff line number Diff line change
@@ -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'])