Skip to content

Commit fe93173

Browse files
Merge pull request #100 from leifj/attribute-generation
Attribute generation
2 parents dc4cce1 + 407904c commit fe93173

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module: satosa.micro_services.attribute_generation.AddSyntheticAttributes
2+
name: AddSyntheticAttributes
3+
config:
4+
synthetic_attributes:
5+
target_provider1:
6+
requester1:
7+
eduPersonAffiliation: member;employee
8+
default:
9+
default:
10+
schacHomeOrganization: {{eduPersonPrincipalName.scope}}
11+
schacHomeOrganizationType: tomfoolery provider

setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222
"PyYAML",
2323
"gunicorn",
2424
"Werkzeug",
25-
"click"
25+
"click",
26+
"pystache"
2627
],
2728
extras_require={
28-
"ldap": ["ldap3"],
29+
"ldap": ["ldap3"]
2930
},
3031
zip_safe=False,
3132
classifiers=[
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import re
2+
import pystache
3+
4+
from .base import ResponseMicroService
5+
from ..util import get_dict_defaults
6+
7+
class MustachAttrValue(object):
8+
def __init__(self, attr_name, values):
9+
self._attr_name = attr_name
10+
self._values = values
11+
if any(['@' in v for v in values]):
12+
local_parts = []
13+
domain_parts = []
14+
scopes = dict()
15+
for v in values:
16+
(local_part, sep, domain_part) = v.partition('@')
17+
# probably not needed now...
18+
local_parts.append(local_part)
19+
domain_parts.append(domain_part)
20+
scopes[domain_part] = True
21+
self._scopes = list(scopes.keys())
22+
else:
23+
self._scopes = None
24+
25+
def __str__(self):
26+
return ";".join(self._values)
27+
28+
@property
29+
def values(self):
30+
[{self._attr_name: v} for v in self._values]
31+
32+
@property
33+
def value(self):
34+
if len(self._values) == 1:
35+
return self._values[0]
36+
else:
37+
return self._values
38+
39+
@property
40+
def first(self):
41+
if len(self._values) > 0:
42+
return self._values[0]
43+
else:
44+
return ""
45+
46+
@property
47+
def scope(self):
48+
if self._scopes is not None:
49+
return self._scopes[0]
50+
return ""
51+
52+
53+
class AddSyntheticAttributes(ResponseMicroService):
54+
"""
55+
A class that add generated or synthetic attributes to a response set. Attribute
56+
generation is done using mustach (http://mustache.github.io) templates. The
57+
following example configuration illustrates most common features:
58+
59+
```yaml
60+
module: satosa.micro_services.attribute_generation.AddSyntheticAttributes
61+
name: AddSyntheticAttributes
62+
config:
63+
synthetic_attributes:
64+
target_provider1:
65+
requester1:
66+
eduPersonAffiliation: member;employee
67+
default:
68+
default:
69+
schacHomeOrganization: {{eduPersonPrincipalName.scope}}
70+
schacHomeOrganizationType: tomfoolery provider
71+
72+
```
73+
74+
The use of "" and 'default' is synonymous. Attribute rules are not
75+
overloaded or inherited. For instance a response from "target_provider1"
76+
and requester1 in the above config will generate a (static) attribute
77+
set of 'member' and 'employee' for the eduPersonAffiliation attribute
78+
and nothing else. Note that synthetic attributes override existing
79+
attributes if present.
80+
81+
*Evaluating and interpreting templates*
82+
83+
Attribute values are split on combinations of ';' and newline so that
84+
a template resulting in the following text:
85+
```
86+
a;
87+
b;c
88+
```
89+
results in three attribute values: 'a','b' and 'c'. Templates are
90+
evaluated with a single context that represents the response attributes
91+
before the microservice is processed. De-referencing the attribute
92+
name as in '{{name}}' results in a ';'-separated list of all attribute
93+
values. This notation is useful when you know there is only a single
94+
attribute value in the set.
95+
96+
*Special contexts*
97+
98+
For treating the values as a list - eg for interating using mustach,
99+
use the .values sub-context For instance to synthesize all first-last
100+
name combinations do this:
101+
102+
```
103+
{{#givenName.values}}
104+
{{#sn.values}}{{givenName}} {{sn}}{{/sn.values}}
105+
{{/givenName.values}}
106+
```
107+
108+
Note that the .values sub-context behaves as if it is an iterator
109+
over single-value context with the same key name as the original
110+
attribute name.
111+
112+
The .scope sub-context evalues to the right-hand part of any @
113+
sign. This is assumed to be single valued.
114+
115+
The .first sub-context evalues to the first value of a context
116+
which may be safer to use if the attribute is multivalued but
117+
you don't care which value is used in a template.
118+
"""
119+
120+
def __init__(self, config, *args, **kwargs):
121+
super().__init__(*args, **kwargs)
122+
self.synthetic_attributes = config["synthetic_attributes"]
123+
124+
def _synthesize(self, attributes, requester, provider):
125+
syn_attributes = dict()
126+
context = dict()
127+
128+
for attr_name,values in attributes.items():
129+
context[attr_name] = MustachAttrValue(attr_name, values)
130+
131+
recipes = get_dict_defaults(self.synthetic_attributes, requester, provider)
132+
for attr_name, fmt in recipes.items():
133+
syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))]
134+
return syn_attributes
135+
136+
def process(self, context, data):
137+
data.attributes.update(self._synthesize(data.attributes, data.requester, data.auth_info.issuer))
138+
return super().process(context, data)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from satosa.internal_data import InternalResponse, AuthenticationInformation
2+
from satosa.micro_services.attribute_generation import AddSyntheticAttributes
3+
from satosa.exception import SATOSAAuthenticationError
4+
from satosa.context import Context
5+
6+
class TestAddSyntheticAttributes:
7+
def create_syn_service(self, synthetic_attributes):
8+
authz_service = AddSyntheticAttributes(config=dict(synthetic_attributes=synthetic_attributes),
9+
name="test_gen",
10+
base_url="https://satosa.example.com")
11+
authz_service.next = lambda ctx, data: data
12+
return authz_service
13+
14+
def test_generate_static(self):
15+
synthetic_attributes = {
16+
"": { "default": {"a0": "value1;value2" }}
17+
}
18+
authz_service = self.create_syn_service(synthetic_attributes)
19+
resp = InternalResponse(AuthenticationInformation(None, None, None))
20+
resp.attributes = {
21+
"a1": ["test@example.com"],
22+
}
23+
ctx = Context()
24+
ctx.state = dict()
25+
authz_service.process(ctx, resp)
26+
assert("value1" in resp.attributes['a0'])
27+
assert("value2" in resp.attributes['a0'])
28+
assert("test@example.com" in resp.attributes['a1'])
29+
30+
def test_generate_mustache1(self):
31+
synthetic_attributes = {
32+
"": { "default": {"a0": "{{kaka}}#{{eppn.scope}}" }}
33+
}
34+
authz_service = self.create_syn_service(synthetic_attributes)
35+
resp = InternalResponse(AuthenticationInformation(None, None, None))
36+
resp.attributes = {
37+
"kaka": ["kaka1"],
38+
"eppn": ["a@example.com","b@example.com"]
39+
}
40+
ctx = Context()
41+
ctx.state = dict()
42+
authz_service.process(ctx, resp)
43+
assert("kaka1#example.com" in resp.attributes['a0'])
44+
assert("kaka1" in resp.attributes['kaka'])
45+
assert("a@example.com" in resp.attributes['eppn'])
46+
assert("b@example.com" in resp.attributes['eppn'])
47+
48+
def test_generate_mustache2(self):
49+
synthetic_attributes = {
50+
"": { "default": {"a0": "{{kaka.first}}#{{eppn.scope}}" }}
51+
}
52+
authz_service = self.create_syn_service(synthetic_attributes)
53+
resp = InternalResponse(AuthenticationInformation(None, None, None))
54+
resp.attributes = {
55+
"kaka": ["kaka1","kaka2"],
56+
"eppn": ["a@example.com","b@example.com"]
57+
}
58+
ctx = Context()
59+
ctx.state = dict()
60+
authz_service.process(ctx, resp)
61+
assert("kaka1#example.com" in resp.attributes['a0'])
62+
assert("kaka1" in resp.attributes['kaka'])
63+
assert("a@example.com" in resp.attributes['eppn'])
64+
assert("b@example.com" in resp.attributes['eppn'])

0 commit comments

Comments
 (0)