-
Notifications
You must be signed in to change notification settings - Fork 133
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
Attribute generation #100
Changes from 4 commits
3b3a9cf
4ec911b
6ec37c5
f453b76
7f762af
2823291
f772efd
8c0b6f1
407904c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
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, 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 1 == len(self._values): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just picky but I really find len(self._values) == 1 is much easier to read. |
||
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 fist-last | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 = _config(self.synthetic_attributes, requester, provider) | ||
print(context) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rogue print statement |
||
for attr_name, fmt in recipes: | ||
print(fmt) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rogue print statement |
||
syn_attributes[attr_name] = [v.strip().strip(';') for v in re.split("[;\n]+", pystache.render(fmt, context))] | ||
print(syn_attributes) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rogue print statement |
||
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) |
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']) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you want to update your code to use the util function added in #99?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was planning to do that in a later refactor - dunno if I can cherry-pick that function