Skip to content

Commit f0c3ea6

Browse files
committed
Merge branch 'upstream' into signed-assertions
2 parents 583f2be + fe93173 commit f0c3ea6

File tree

11 files changed

+455
-5
lines changed

11 files changed

+455
-5
lines changed

doc/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,18 @@ config:
242242
idp-entity-id1
243243
sp-entity-id1:
244244
exclude: ["givenName"]
245+
246+
247+
The custom_attribute_release mechanism supports defaults based on idp and sp entity Id by specifying "" or "default"
248+
as the key in the dict. For instance in order to exclude givenName for any sp or idp do this:
249+
250+
```yaml
251+
config:
252+
config: [...]
253+
custom_attribute_release:
254+
"default":
255+
"":
256+
exclude: ["givenName"]
245257

246258

247259
#### Backend
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module: satosa.micro_services.attribute_authorization.AttributeAuthorization
2+
name: AttributeAuthorization
3+
config:
4+
attribute_allow:
5+
target_provider1:
6+
requester1:
7+
attr1:
8+
- "^foo:bar$"
9+
- "^kaka$"
10+
default:
11+
attr1:
12+
- "plupp@.+$"
13+
"":
14+
"":
15+
attr2:
16+
- "^knytte:.*$"
17+
attribute_deny:
18+
default:
19+
default:
20+
eppn:
21+
- "^[^@]+$"
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=[

src/satosa/backends/saml2.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,16 @@ def _translate_response(self, response, state):
214214
issuer = response.response.issuer.text
215215

216216
auth_info = AuthenticationInformation(auth_class_ref, timestamp, issuer)
217-
internal_resp = InternalResponse(auth_info=auth_info)
217+
internal_resp = SAMLInternalResponse(auth_info=auth_info)
218218

219219
internal_resp.user_id = response.get_subject().text
220220
internal_resp.attributes = self.converter.to_internal(self.attribute_profile, response.ava)
221+
222+
# The SAML response may not include a NameID
223+
try:
224+
internal_resp.name_id = response.assertion.subject.name_id
225+
except AttributeError:
226+
pass
221227

222228
satosa_logging(logger, logging.DEBUG, "received attributes:\n%s" % json.dumps(response.ava, indent=4), state)
223229
return internal_resp
@@ -315,3 +321,32 @@ def get_metadata_desc(self):
315321

316322
entity_descriptions.append(description)
317323
return entity_descriptions
324+
325+
class SAMLInternalResponse(InternalResponse):
326+
"""
327+
Like the parent InternalResponse, holds internal representation of
328+
service related data, but includes additional details relevant to
329+
SAML interoperability.
330+
331+
:type name_id: instance of saml2.saml.NameID from pysaml2
332+
"""
333+
def __init__(self, auth_info=None):
334+
super().__init__(auth_info)
335+
336+
self.name_id = None
337+
338+
def to_dict(self):
339+
"""
340+
Converts a SAMLInternalResponse object to a dict
341+
:rtype: dict[str, dict[str, str] | str]
342+
:return: A dict representation of the object
343+
"""
344+
_dict = super().to_dict()
345+
346+
if self.name_id:
347+
_dict['name_id'] = {self.name_id.format : self.name_id.text}
348+
else:
349+
_dict['name_id'] = None
350+
351+
return _dict
352+

src/satosa/frontends/saml2.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ..response import Response
2222
from ..response import ServiceError
2323
from ..saml_util import make_saml_response
24+
from ..util import get_dict_defaults
2425

2526
logger = logging.getLogger(__name__)
2627

@@ -271,9 +272,10 @@ def _handle_authn_response(self, context, internal_response, idp):
271272
else:
272273
auth_info["class_ref"] = internal_response.auth_info.auth_class_ref
273274

275+
auth_info["authn_auth"] = internal_response.auth_info.issuer
276+
274277
if self.custom_attribute_release:
275-
custom_release_per_idp = self.custom_attribute_release.get(internal_response.auth_info.issuer, {})
276-
custom_release = custom_release_per_idp.get(resp_args["sp_entity_id"], {})
278+
custom_release = get_dict_defaults(self.custom_attribute_release, internal_response.auth_info.issuer, resp_args["sp_entity_id"])
277279
attributes_to_remove = custom_release.get("exclude", [])
278280
for k in attributes_to_remove:
279281
ava.pop(k, None)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import re
2+
3+
from .base import ResponseMicroService
4+
from ..exception import SATOSAAuthenticationError
5+
from ..util import get_dict_defaults
6+
7+
class AttributeAuthorization(ResponseMicroService):
8+
9+
"""
10+
A microservice that performs simple regexp-based authorization based on response
11+
attributes. The configuration assumes a dict with two keys: attributes_allow
12+
and attributes_deny. An examples speaks volumes:
13+
14+
```yaml
15+
config:
16+
attribute_allow:
17+
target_provider1:
18+
requester1:
19+
attr1:
20+
- "^foo:bar$"
21+
- "^kaka$"
22+
default:
23+
attr1:
24+
- "plupp@.+$"
25+
"":
26+
"":
27+
attr2:
28+
- "^knytte:.*$"
29+
attribute_deny:
30+
default:
31+
default:
32+
eppn:
33+
- "^[^@]+$"
34+
35+
```
36+
37+
The use of "" and 'default' is synonymous. Attribute rules are not overloaded
38+
or inherited. For instance a response from "provider2" would only be allowed
39+
through if the eppn attribute had all values containing an '@' (something
40+
perhaps best implemented via an allow rule in practice). Responses from
41+
target_provider1 bound for requester1 would be allowed through only if attr1
42+
contained foo:bar or kaka. Note that attribute filters (the leaves of the
43+
structure above) are ORed together - i.e any attribute match is sufficient.
44+
"""
45+
46+
def __init__(self, config, *args, **kwargs):
47+
super().__init__(*args, **kwargs)
48+
self.attribute_allow = config.get("attribute_allow", {})
49+
self.attribute_deny = config.get("attribute_deny", {})
50+
51+
def _check_authz(self, context, attributes, requester, provider):
52+
for attribute_name, attribute_filters in get_dict_defaults(self.attribute_allow, requester, provider).items():
53+
if attribute_name in attributes:
54+
if not any([any(filter(re.compile(af).search, attributes[attribute_name])) for af in attribute_filters]):
55+
raise SATOSAAuthenticationError(context.state, "Permission denied")
56+
57+
for attribute_name, attribute_filters in get_dict_defaults(self.attribute_deny, requester, provider).items():
58+
if attribute_name in attributes:
59+
if any([any(filter(re.compile(af).search, attributes[attribute_name])) for af in attribute_filters]):
60+
raise SATOSAAuthenticationError(context.state, "Permission denied")
61+
62+
def process(self, context, data):
63+
self._check_authz(context, data.attributes, data.requester, data.auth_info.issuer)
64+
return super().process(context, data)
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)

src/satosa/util.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
logger = logging.getLogger(__name__)
99

10+
def get_dict_defaults(d, *keys):
11+
for key in keys:
12+
d = d.get(key, d.get("", d.get("default", {})))
13+
return d
1014

1115
def rndstr(size=16, alphabet=""):
1216
"""

0 commit comments

Comments
 (0)