Skip to content

Commit 829ff0a

Browse files
skorandac00kiemon5ter
authored andcommitted
Better config processing for LDAP attribute store
Better configuration processing logic for the LDAP attribute store microservice. Signed-off-by: Ivan Kanakarakis <[email protected]>
1 parent f1075c1 commit 829ff0a

File tree

1 file changed

+115
-90
lines changed

1 file changed

+115
-90
lines changed

src/satosa/micro_services/ldap_attribute_store.py

Lines changed: 115 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
SATOSA microservice that uses an identifier asserted by
2+
SATOSA microservice that uses an identifier asserted by
33
the home organization SAML IdP as a key to search an LDAP
44
directory for a record and then consume attributes from
55
the record and assert them to the receiving SP.
@@ -16,6 +16,12 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
class LdapAttributeStoreException(Exception):
20+
def __init__(self, value):
21+
self.value = value
22+
def __str__(self):
23+
return "LdapAttributeStoreException: {}".format(self.value)
24+
1925
class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService):
2026
"""
2127
Use identifier provided by the backend authentication service
@@ -24,18 +30,44 @@ class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService):
2430
"""
2531
logprefix = "LDAP_ATTRIBUTE_STORE:"
2632

33+
# Allowed configuration options for the microservice. Any key
34+
# in the config dictionary passed into the __init__() method
35+
# that is not a key in this dictionary is treated as the
36+
# entityID for a per-SP configuration.
37+
#
38+
# The keys are the allowed configuration options. The values
39+
# are a list of [default value, if required or not]. Note that
40+
# required here means required as part of an effective
41+
# configuration during a particular flow, so it applies
42+
# to the default configuration overridden with any per-SP
43+
# configuration details.
44+
config_options = {
45+
'bind_dn' : {'default' : None, 'required' : True},
46+
'bind_password' : {'default' : None, 'required' : True},
47+
'clear_input_attributes' : {'default' : False, 'required' : False},
48+
'ignore' : {'default' : False, 'required' : False},
49+
'ldap_identifier_attribute' : {'default' : None, 'required' : True},
50+
'ldap_url' : {'default' : None, 'required' : True},
51+
'on_ldap_search_result_empty' : {'default' : None, 'required' : False},
52+
'ordered_identifier_candidates' : {'default' : None, 'required' : True},
53+
'search_base' : {'default' : None, 'required' : True},
54+
'search_return_attributes' : {'default' : None, 'required' : True},
55+
'user_id_from_attrs' : {'default' : [], 'required' : False}
56+
}
57+
2758
def __init__(self, config, *args, **kwargs):
2859
super().__init__(*args, **kwargs)
2960
self.config = config
61+
self.logprefix = LdapAttributeStore.logprefix
3062

31-
def constructFilterValue(self, candidate, data):
63+
def _constructFilterValue(self, candidate, data):
3264
"""
3365
Construct and return a LDAP directory search filter value from the
3466
candidate identifier.
3567
36-
Argument 'canidate' is a dictionary with one required key and
68+
Argument 'canidate' is a dictionary with one required key and
3769
two optional keys:
38-
70+
3971
key required value
4072
--------------- -------- ---------------------------------
4173
attribute_names Y list of identifier names
@@ -50,7 +82,7 @@ def constructFilterValue(self, candidate, data):
5082
If the attribute_names list consists of more than one identifier
5183
name then the values of the identifiers will be concatenated together
5284
to create the filter value.
53-
85+
5486
If one of the identifier names in the attribute_names is the string
5587
'name_id' then the NameID value with format name_id_format
5688
will be concatenated to the filter value.
@@ -87,9 +119,9 @@ def constructFilterValue(self, candidate, data):
87119
if candidate['name_id_format'] in name_id:
88120
nameid_value = name_id[candidate['name_id_format']]
89121

90-
# Only add the NameID value asserted by the IdP if it is not already
122+
# Only add the NameID value asserted by the IdP if it is not already
91123
# 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
124+
# have been known, for example, to assert the value of eduPersonPrincipalName
93125
# in the value for SAML2 persistent NameID as well as asserting
94126
# eduPersonPrincipalName.
95127
if nameid_value not in values:
@@ -122,21 +154,67 @@ def constructFilterValue(self, candidate, data):
122154

123155
return value
124156

125-
def process(self, context, data):
126-
logprefix = LdapAttributeStore.logprefix
127-
self.logprefix = logprefix
128-
self.context = context
157+
def _copyConfigOptions(self, source, target):
158+
"""
159+
Copy allowed configuration options from the source
160+
dictionary to the target dictionary.
161+
"""
162+
for option in LdapAttributeStore.config_options:
163+
if option in source:
164+
target[option] = source[option]
165+
166+
def _getEffectiveConfig(self, entityID = None, state = None):
167+
"""
168+
Get the effective configuration for the SP with entityID
169+
or the default configuration if no entityID.
170+
"""
171+
logprefix = self.logprefix
172+
173+
# Set microservice defaults for available configuration options.
174+
base_config = { key: value['default'] for key, value in LdapAttributeStore.config_options.items()}
175+
effective_config = copy.deepcopy(base_config)
176+
177+
# Process default input configuration to the microservice.
178+
self._copyConfigOptions(self.config, effective_config)
179+
clean_for_logging = self._hideConfigSecrets(effective_config)
180+
satosa_logging(logger, logging.DEBUG, "{} Using default configuration {}".format(logprefix, clean_for_logging), state)
181+
182+
# Process per-SP input configuration to the microservice.
183+
if entityID:
184+
if entityID in self.config:
185+
self._copyConfigOptions(self.config[entityID], effective_config)
186+
clean_for_logging = self._hideConfigSecrets(effective_config)
187+
satosa_logging(logger, logging.DEBUG, "{} For SP {} using configuration {}".format(logprefix, entityID, clean_for_logging), state)
188+
189+
# Check effective configuration against required configuration details.
190+
for config_opt, required in {key: value['required'] for key, value in LdapAttributeStore.config_options.items()}.items():
191+
if required:
192+
if not effective_config[config_opt]:
193+
raise LdapAttributeStoreException("Configuration option {} is required but missing".format(config_opt))
194+
195+
return effective_config
196+
197+
def _hideConfigSecrets(self, config):
198+
"""
199+
Make a deep copy of the input config dictionary and
200+
replace the bind password with a dummy string and
201+
return the copy.
202+
"""
203+
clean_config = copy.deepcopy(config)
204+
if 'bind_password' in clean_config:
205+
clean_config['bind_password'] = 'XXXXXXXX'
129206

130-
# Initialize the configuration to use as the default configuration
131-
# that is passed during initialization.
132-
config = self.config
133-
configClean = copy.deepcopy(config)
134-
if 'bind_password' in configClean:
135-
configClean['bind_password'] = 'XXXXXXXX'
207+
return clean_config
136208

137-
satosa_logging(logger, logging.DEBUG, "{} Using default configuration {}".format(logprefix, configClean), context.state)
209+
def process(self, context, data):
210+
"""
211+
Default interface for microservices. Process the input data for
212+
the input context.
213+
"""
214+
self.context = context
215+
logprefix = self.logprefix
138216

139-
# Find the entityID for the SP that initiated the flow
217+
# Find the entityID for the SP that initiated the flow.
140218
try:
141219
spEntityID = context.state.state_dict['SATOSA_BASE']['requester']
142220
except KeyError as err:
@@ -145,73 +223,15 @@ def process(self, context, data):
145223

146224
satosa_logging(logger, logging.DEBUG, "{} entityID for the SP requester is {}".format(logprefix, spEntityID), context.state)
147225

148-
# Examine our configuration to determine if there is a per-SP configuration
149-
if spEntityID in self.config:
150-
config = self.config[spEntityID]
151-
configClean = copy.deepcopy(config)
152-
if 'bind_password' in configClean:
153-
configClean['bind_password'] = 'XXXXXXXX'
154-
satosa_logging(logger, logging.DEBUG, "{} For SP {} using configuration {}".format(logprefix, spEntityID, configClean), context.state)
155-
156-
# Obtain configuration details from the per-SP configuration or the default configuration
226+
# Get the effective configuration for the SP.
157227
try:
158-
if 'ldap_url' in config:
159-
ldap_url = config['ldap_url']
160-
else:
161-
ldap_url = self.config['ldap_url']
162-
if 'bind_dn' in config:
163-
bind_dn = config['bind_dn']
164-
else:
165-
bind_dn = self.config['bind_dn']
166-
if 'bind_dn' in config:
167-
bind_password = config['bind_password']
168-
else:
169-
bind_password = self.config['bind_password']
170-
if 'search_base' in config:
171-
search_base = config['search_base']
172-
else:
173-
search_base = self.config['search_base']
174-
if 'search_return_attributes' in config:
175-
search_return_attributes = config['search_return_attributes']
176-
else:
177-
search_return_attributes = self.config['search_return_attributes']
178-
if 'ordered_identifier_candidates' in config:
179-
ordered_identifier_candidates = config['ordered_identifier_candidates']
180-
else:
181-
ordered_identifier_candidates = self.config['ordered_identifier_candidates']
182-
if 'ldap_identifier_attribute' in config:
183-
ldap_identifier_attribute = config['ldap_identifier_attribute']
184-
else:
185-
ldap_identifier_attribute = self.config['ldap_identifier_attribute']
186-
if 'clear_input_attributes' in config:
187-
clear_input_attributes = config['clear_input_attributes']
188-
elif 'clear_input_attributes' in self.config:
189-
clear_input_attributes = self.config['clear_input_attributes']
190-
else:
191-
clear_input_attributes = False
192-
if 'user_id_from_attrs' in config:
193-
user_id_from_attrs = config['user_id_from_attrs']
194-
elif 'user_id_from_attrs' in self.config:
195-
user_id_from_attrs = self.config['user_id_from_attrs']
196-
else:
197-
user_id_from_attrs = []
198-
if 'on_ldap_search_result_empty' in config:
199-
on_ldap_search_result_empty = config['on_ldap_search_result_empty']
200-
elif 'on_ldap_search_result_empty' in self.config:
201-
on_ldap_search_result_empty = self.config['on_ldap_search_result_empty']
202-
else:
203-
on_ldap_search_result_empty = None
204-
if 'ignore' in config:
205-
ignore = True
206-
else:
207-
ignore = False
208-
209-
except KeyError as err:
210-
satosa_logging(logger, logging.ERROR, "{} Configuration '{}' is missing".format(logprefix, err), context.state)
228+
config = self._getEffectiveConfig(spEntityID, context.state)
229+
except LdapAttributeStoreException as e:
230+
satosa_logging(logger, logging.ERROR, "{} Caught exception: {}".format(logprefix, e), context.state)
211231
return super().process(context, data)
212232

213233
# Ignore this SP entirely if so configured.
214-
if ignore:
234+
if config['ignore']:
215235
satosa_logging(logger, logging.INFO, "{} Ignoring SP {}".format(logprefix, spEntityID), None)
216236
return super().process(context, data)
217237

@@ -221,8 +241,8 @@ def process(self, context, data):
221241

222242
# Loop over the configured list of identifiers from the IdP to consider and find
223243
# asserted values to construct the ordered list of values for the LDAP search filters.
224-
for candidate in ordered_identifier_candidates:
225-
value = self.constructFilterValue(candidate, data)
244+
for candidate in config['ordered_identifier_candidates']:
245+
value = self._constructFilterValue(candidate, data)
226246

227247
# If we have constructed a non empty value then add it as the next filter value
228248
# to use when searching for the user record.
@@ -235,22 +255,24 @@ def process(self, context, data):
235255
record = None
236256

237257
try:
258+
ldap_url = config['ldap_url']
238259
satosa_logging(logger, logging.DEBUG, "{} Using LDAP URL {}".format(logprefix, ldap_url), context.state)
239260
server = ldap3.Server(ldap_url)
240261

262+
bind_dn = config['bind_dn']
241263
satosa_logging(logger, logging.DEBUG, "{} Using bind DN {}".format(logprefix, bind_dn), context.state)
242-
connection = ldap3.Connection(server, bind_dn, bind_password, auto_bind=True)
264+
connection = ldap3.Connection(server, bind_dn, config['bind_password'], auto_bind=True)
243265
satosa_logging(logger, logging.DEBUG, "{} Connected to LDAP server".format(logprefix), context.state)
244266

245267
for filterVal in filterValues:
246268
if record:
247269
break
248270

249-
search_filter = '({0}={1})'.format(ldap_identifier_attribute, filterVal)
271+
search_filter = '({0}={1})'.format(config['ldap_identifier_attribute'], filterVal)
250272
satosa_logging(logger, logging.DEBUG, "{} Constructed search filter {}".format(logprefix, search_filter), context.state)
251273

252274
satosa_logging(logger, logging.DEBUG, "{} Querying LDAP server...".format(logprefix), context.state)
253-
connection.search(search_base, search_filter, attributes=search_return_attributes.keys())
275+
connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys())
254276
satosa_logging(logger, logging.DEBUG, "{} Done querying LDAP server".format(logprefix), context.state)
255277

256278
responses = connection.response
@@ -259,10 +281,10 @@ def process(self, context, data):
259281
# for now consider only the first record found (if any)
260282
if len(responses) > 0:
261283
if len(responses) > 1:
262-
satosa_logging(logger, logging.WARN, "{} LDAP server returned {} records using IdP asserted attribute {}".format(logprefix, len(responses), identifier), context.state)
284+
satosa_logging(logger, logging.WARN, "{} LDAP server returned {} records using search filter value {}".format(logprefix, len(responses), filterVar), context.state)
263285
record = responses[0]
264286
break
265-
287+
266288
except Exception as err:
267289
satosa_logging(logger, logging.ERROR, "{} Caught exception: {}".format(logprefix, err), context.state)
268290
return super().process(context, data)
@@ -273,7 +295,7 @@ def process(self, context, data):
273295

274296
# Before using a found record, if any, to populate attributes
275297
# clear any attributes incoming to this microservice if so configured.
276-
if clear_input_attributes:
298+
if config['clear_input_attributes']:
277299
satosa_logging(logger, logging.DEBUG, "{} Clearing values for these input attributes: {}".format(logprefix, data.attributes), context.state)
278300
data.attributes = {}
279301

@@ -283,6 +305,7 @@ def process(self, context, data):
283305
satosa_logging(logger, logging.DEBUG, "{} Record with DN {} has attributes {}".format(logprefix, record["dn"], record["attributes"]), context.state)
284306

285307
# Populate attributes as configured.
308+
search_return_attributes = config['search_return_attributes']
286309
for attr in search_return_attributes.keys():
287310
if attr in record["attributes"]:
288311
if record["attributes"][attr]:
@@ -293,6 +316,7 @@ def process(self, context, data):
293316

294317
# Populate input for NameID if configured. SATOSA core does the hashing of input
295318
# to create a persistent NameID.
319+
user_id_from_attrs = config['user_id_from_attrs']
296320
if user_id_from_attrs:
297321
userId = ""
298322
for attr in user_id_from_attrs:
@@ -317,6 +341,7 @@ def process(self, context, data):
317341

318342
else:
319343
satosa_logging(logger, logging.WARN, "{} No record found in LDAP so no attributes will be added".format(logprefix), context.state)
344+
on_ldap_search_result_empty = config['on_ldap_search_result_empty']
320345
if on_ldap_search_result_empty:
321346
# Redirect to the configured URL with
322347
# the entityIDs for the target SP and IdP used by the user

0 commit comments

Comments
 (0)