Skip to content

Commit 664d329

Browse files
skorandac00kiemon5ter
authored andcommitted
Refactor determining primary identifier from IdP for LDAP query
Refactored how the attributes and NameID values asserted by the IdP are processed to determine the identifier value to use when constructing the LDAP filter value that is used to search for the person record in LDAP. The result is a breaking change in configuration syntax. The configuration option idp_identifiers is no longer allowed and is replaced by ordered_identifier_candidates.
1 parent 90c16d7 commit 664d329

File tree

2 files changed

+116
-59
lines changed

2 files changed

+116
-59
lines changed

example/plugins/microservices/ldap_attribute_store.yaml.example

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,23 @@ config:
1212
mail: mail
1313
employeeNumber: employeenumber
1414
isMemberOf: ismemberof
15-
idp_identifiers:
16-
# Ordered list of identifiers asserted as attributes by
17-
# IdP to use when constructing search filter to find
18-
# user record in LDAP directory. This example searches
19-
# in order for eduPersonUniqueId, eduPersonPrincipalName
20-
# combined with SAML persistent, eduPersonPrincipalName
21-
# combined with eduPersonTargetedId,
22-
# eduPersonPrincipalName, SAML persistent, and
23-
# eduPersonTargetedId.
24-
- epuid
25-
-
26-
- eppn
27-
- name_id: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
28-
-
29-
- eppn
30-
- edupersontargetedid
31-
- eppn
32-
- name_id: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
33-
- edupersontargetedid
15+
ordered_identifier_candidates:
16+
# Ordered list of identifiers to use when constructing the
17+
# search filter to find the user record in LDAP directory.
18+
# This example searches in order for eduPersonUniqueId, eduPersonPrincipalName
19+
# combined with SAML persistent NameID, eduPersonPrincipalName
20+
# combined with eduPersonTargetedId, eduPersonPrincipalName,
21+
# SAML persistent NameID, and eduPersonTargetedId.
22+
- attribute_names: [epuid]
23+
- attribute_names: [eppn, name_id]
24+
name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
25+
- attribute_names: [eppn, edupersontargetedid]
26+
- attribute_names: [eppn]
27+
- attribute_names: [name_id]
28+
name_id_format: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
29+
add_scope: issuer_entityid
30+
- attribute_names: [edupersontargetedid]
31+
add_scope: issuer_entityid
3432
ldap_identifier_attribute: uid
3533
# Whether to clear values for attributes incoming
3634
# to this microservice. Default is no or false.
@@ -49,6 +47,9 @@ config:
4947
# For example:
5048
https://sp.myserver.edu/shibboleth-sp
5149
search_base: ou=People,o=MyVO,dc=example,dc=org
52-
eduPersonPrincipalName: employeenumber
50+
search_return_attributes:
51+
employeeNumber: employeenumber
52+
ordered_identifier_candidates:
53+
- attribute_names: [eppn]
5354
user_id_from_attrs:
5455
- uid

src/satosa/micro_services/ldap_attribute_store.py

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -28,48 +28,104 @@ def __init__(self, config, *args, **kwargs):
2828
super().__init__(*args, **kwargs)
2929
self.config = config
3030

31-
def constructFilterValue(self, identifier, data):
31+
def constructFilterValue(self, candidate, data):
3232
"""
3333
Construct and return a LDAP directory search filter value from the
34-
data asserted by the IdP based on the input identifier.
34+
candidate identifier.
3535
36-
If the input identifier is a list of identifiers then this
37-
method is called recursively and the values concatenated together.
36+
Argument 'canidate' is a dictionary with one required key and
37+
two optional keys:
38+
39+
key required value
40+
--------------- -------- ---------------------------------
41+
attribute_names Y list of identifier names
3842
39-
If the input identifier is a dictionary with 'name_id' as the key
40-
and a NameID format as value than the NameID value (if any) asserted
41-
by the IdP for that format is used as the value.
43+
name_id_format N NameID format (string)
44+
45+
add_scope N "issuer_entityid" or other string
46+
47+
Argument 'data' is that object passed into the microservice
48+
method process().
49+
50+
If the attribute_names list consists of more than one identifier
51+
name then the values of the identifiers will be concatenated together
52+
to create the filter value.
53+
54+
If one of the identifier names in the attribute_names is the string
55+
'name_id' then the NameID value with format name_id_format
56+
will be concatenated to the filter value.
57+
58+
If the add_scope key is present with value 'issuer_entityid' then the
59+
entityID for the IdP will be concatenated to "scope" the value. If the
60+
string is any other value it will be directly concatenated.
4261
"""
43-
value = ""
44-
45-
# If the identifier is a list of identifiers then loop over them
46-
# calling ourself recursively and concatenate the values from
47-
# the identifiers together.
48-
if isinstance(identifier, list):
49-
for i in identifier:
50-
value += self.constructFilterValue(i, data)
51-
52-
# If the identifier is a dictionary with key 'name_id' then the value
53-
# is a NameID format. Look for a NameID asserted by the IdP with that
54-
# format and if found use its value.
55-
elif isinstance(identifier, dict):
56-
if 'name_id' in identifier:
57-
nameIdFormat = identifier['name_id']
58-
if 'name_id' in data.to_dict():
59-
if nameIdFormat in data.to_dict()['name_id']:
60-
value += data.to_dict()['name_id'][nameIdFormat]
61-
62-
# The identifier is not a list or dictionary so just consume the asserted values
63-
# for this single identifier to create the value.
64-
else:
65-
if identifier in data.attributes:
66-
for v in data.attributes[identifier]:
67-
value += v
62+
logprefix = self.logprefix
63+
context = self.context
64+
65+
attributes = data.attributes
66+
satosa_logging(logger, logging.DEBUG, "{} Input attributes {}".format(logprefix, attributes), context.state)
67+
68+
# Get the values configured list of identifier names for this candidate
69+
# and substitute None if there are no values for a configured identifier.
70+
values = []
71+
for identifier_name in candidate['attribute_names']:
72+
v = attributes.get(identifier_name, None)
73+
if isinstance(v, list):
74+
v = v[0]
75+
values.append(v)
76+
satosa_logging(logger, logging.DEBUG, "{} Found candidate values {}".format(logprefix, values), context.state)
77+
78+
# If one of the configured identifier names is name_id then if there is also a configured
79+
# name_id_format add the value for the NameID of that format if it was asserted by the IdP
80+
# or else add the value None.
81+
if 'name_id' in candidate['attribute_names']:
82+
nameid_value = None
83+
if 'name_id' in data.to_dict():
84+
name_id = data.to_dict()['name_id']
85+
satosa_logging(logger, logging.DEBUG, "{} IdP asserted NameID {}".format(logprefix, name_id), context.state)
86+
if 'name_id_format' in candidate:
87+
if candidate['name_id_format'] in name_id:
88+
nameid_value = name_id[candidate['name_id_format']]
89+
90+
# Only add the NameID value asserted by the IdP if it is not already
91+
# in the list of values. This is necessary because some non-compliant IdPs
92+
# have been known, for example, to assert the value of eduPersonPrincipalName
93+
# in the value for SAML2 persistent NameID as well as asserting
94+
# eduPersonPrincipalName.
95+
if nameid_value not in values:
96+
satosa_logging(logger, logging.DEBUG, "{} Added NameID {} to candidate values".format(logprefix, nameid_value), context.state)
97+
values.append(nameid_value)
98+
else:
99+
satosa_logging(logger, logging.WARN, "{} NameID {} value also asserted as attribute value".format(logprefix, nameid_value), context.state)
100+
101+
# If no value was asserted by the IdP for one of the configured list of identifier names
102+
# for this candidate then go onto the next candidate.
103+
if None in values:
104+
satosa_logging(logger, logging.DEBUG, "{} Candidate is missing value so skipping".format(logprefix), context.state)
105+
return None
106+
107+
# All values for the configured list of attribute names are present
108+
# so we can create a value. Add a scope if configured
109+
# to do so.
110+
if 'add_scope' in candidate:
111+
if candidate['add_scope'] == 'issuer_entityid':
112+
scope = data.to_dict()['auth_info']['issuer']
113+
else:
114+
scope = candidate['add_scope']
115+
satosa_logging(logger, logging.DEBUG, "{} Added scope {} to values".format(logprefix, scope), context.state)
116+
values.append(scope)
117+
118+
# Concatenate all values to create the filter value.
119+
value = ''.join(values)
120+
121+
satosa_logging(logger, logging.DEBUG, "{} Constructed filter value {}".format(logprefix, value), context.state)
68122

69123
return value
70124

71125
def process(self, context, data):
72126
logprefix = LdapAttributeStore.logprefix
127+
self.logprefix = logprefix
128+
self.context = context
73129

74130
# Initialize the configuration to use as the default configuration
75131
# that is passed during initialization.
@@ -119,10 +175,10 @@ def process(self, context, data):
119175
search_return_attributes = config['search_return_attributes']
120176
else:
121177
search_return_attributes = self.config['search_return_attributes']
122-
if 'idp_identifiers' in config:
123-
idp_identifiers = config['idp_identifiers']
178+
if 'ordered_identifier_candidates' in config:
179+
ordered_identifier_candidates = config['ordered_identifier_candidates']
124180
else:
125-
idp_identifiers = self.config['idp_identifiers']
181+
ordered_identifier_candidates = self.config['ordered_identifier_candidates']
126182
if 'ldap_identifier_attribute' in config:
127183
ldap_identifier_attribute = config['ldap_identifier_attribute']
128184
else:
@@ -156,14 +212,14 @@ def process(self, context, data):
156212

157213
# Loop over the configured list of identifiers from the IdP to consider and find
158214
# asserted values to construct the ordered list of values for the LDAP search filters.
159-
for identifier in idp_identifiers:
160-
value = self.constructFilterValue(identifier, data)
215+
for candidate in ordered_identifier_candidates:
216+
value = self.constructFilterValue(candidate, data)
161217

162218
# If we have constructed a non empty value then add it as the next filter value
163219
# to use when searching for the user record.
164220
if value:
165221
filterValues.append(value)
166-
satosa_logging(logger, logging.DEBUG, "{} Added identifier {} with value {} to list of search filters".format(logprefix, identifier, value), context.state)
222+
satosa_logging(logger, logging.DEBUG, "{} Added search filter value {} to list of search filters".format(logprefix, value), context.state)
167223

168224
# Initialize an empty LDAP record. The first LDAP record found using the ordered
169225
# list of search filter values will be the record used.
@@ -199,7 +255,7 @@ def process(self, context, data):
199255
break
200256

201257
except Exception as err:
202-
satosa_logging(logger, logging.ERROR, "{} Caught exception: {0}".format(logprefix, err), None)
258+
satosa_logging(logger, logging.ERROR, "{} Caught exception: {}".format(logprefix, err), context.state)
203259
return super().process(context, data)
204260

205261
else:

0 commit comments

Comments
 (0)