Skip to content

Commit 21fe4ce

Browse files
committed
Added field formatters for Okta Connector
1 parent 7e3d39e commit 21fe4ce

File tree

2 files changed

+217
-34
lines changed

2 files changed

+217
-34
lines changed

examples/config files - basic/5 connector-okta.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,85 @@ all_users_filter: 'user.status == "ACTIVE"'
3434
# the valid values are: enterpriseID, federatedID
3535
# If not specified, the default identity type from the main config file is used.
3636
# user_identity_type: federatedID
37+
38+
# (optional) string_encoding (default value given below)
39+
# string_encoding specifies the Unicode string encoding used by the directory.
40+
# All values retrieved from the directory are converted to Unicode before being
41+
# sent to or compared with values on the Adobe side, to avoid encoding issues.
42+
# The value must be a Python codec name or alias, such as 'latin1' or 'big5'.
43+
# See https://docs.python.org/2/library/codecs.html#standard-encodings for details.
44+
#string_encoding: utf8
45+
46+
# (optional) user_identity_type_format (no default)
47+
# user_identity_type_format specifies how to construct a user's desired identity
48+
# type on the Adobe side by combining constant strings with attribute values.
49+
# Any names in curly braces are take as attribute names, and everything including
50+
# the braces will be replaced on a per-user basis with the values of the attributes.
51+
# There is no default value for this setting, because most directories don't contain
52+
# users with different identity types (so setting the default identity type suffices).
53+
# If your directory contains users of different identity types, you should define
54+
# this field to look at the value of an appropriate attribute in your directory.
55+
# For example, if your directory attribute "idType" had one of the values
56+
# adobe, enterprise, or federated in it for each user, you could use:
57+
#user_identity_type_format: "{idType}ID"
58+
59+
# (optional) user_email_format (default value given below)
60+
# user_email_format specifies how to construct a user's email address by
61+
# combining constant strings with the values of specific directory attributes.
62+
# Any names in curly braces are taken as attribute names, and everything including
63+
# the braces will be replaced on a per-user basis with the values of the attributes.
64+
# The default value used here is simple, and suitable for OpenLDAP systems. If you
65+
# are using a non-email-aware AD system, which holds the username separately
66+
# from the domain name, you may want: "{sAMAccountName}@mydomain.com"
67+
# NOTE: for this and every format setting, the constant strings must be in
68+
# the encoding specified by the string_encoding setting, above.
69+
user_email_format: "{email}"
70+
71+
# (optional) user_domain_format (no default value)
72+
# user_domain_format is analogous to user_email_format in syntax, but it
73+
# is used to discover the domain for a given user. If not specified, the
74+
# domain is taken from the domain part of the user's email address.
75+
#user_domain_format: "{domain}"
76+
77+
# (optional) user_username_format (no default value)
78+
# user_username_format specifies how to construct a user's username on the
79+
# Adobe side by combining constant strings with attribute values.
80+
# Any names in curly braces are taken as attribute names, and everything including
81+
# the braces will be replaced on a per-user basis with the values of the attributes.
82+
# This setting should only be used when you are using federatedID and your
83+
# federation configuration specifies username-based login. In all other cases,
84+
# make sure this is not set or returns an empty value, and the user's username
85+
# will be taken from the user's email.
86+
# This example supposes that the department and user_id are concatenated to
87+
# produce a unique username for each user.
88+
#user_username_format: "{department}_{user_id}"
89+
90+
# (optional) user_given_name_format (default value given below)
91+
# user_given_name_format specifies how to construct a user's given name by
92+
# combining constant strings with the values of specific directory attributes.
93+
# Any names in curly braces are taken as attribute names, and everything including
94+
# the braces will be replaced on a per-user basis with the values of the attributes.
95+
# The default value used here is simple, and suitable for OpenLDAP systems.
96+
# NOTE: for this and every format setting, the constant strings must be in
97+
# the encoding specified by the string_encoding setting, above.
98+
#user_given_name_format: "{firstName}"
99+
100+
# (optional) user_surname_format (default value given below)
101+
# user_surname_format specifies how to construct a user's surname by
102+
# combining constant strings with the values of specific directory attributes.
103+
# Any names in curly braces are taken as attribute names, and everything including
104+
# the braces will be replaced on a per-user basis with the values of the attributes.
105+
# The default value used here is simple, and suitable for OpenLDAP systems.
106+
# NOTE: for this and every format setting, the constant strings must be in
107+
# the encoding specified by the string_encoding setting, above.
108+
#user_surname_format: "{lastName}"
109+
110+
# (optional) user_country_code_format (default value given below)
111+
# user_country_code_format specifies how to construct a user's country code by
112+
# combining constant strings with the values of specific directory attributes.
113+
# Any names in curly braces are taken as attribute names, and everything including
114+
# the braces will be replaced on a per-user basis with the values of the attributes.
115+
# The default value used here is simple, and suitable for OpenLDAP systems.
116+
# NOTE: for this and every format setting, the constant strings must be in
117+
# the encoding specified by the string_encoding setting, above.
118+
#user_country_code_format: "{countryCode}"

user_sync/connector/directory_okta.py

Lines changed: 135 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import okta
2222
import six
23+
import string
2324
from okta.framework.OktaError import OktaError
2425

2526
import user_sync.config
@@ -66,13 +67,31 @@ def __init__(self, caller_options):
6667
'{group}')
6768
builder.set_string_value('all_users_filter',
6869
'user.status == "ACTIVE"')
70+
builder.set_string_value('string_encoding', 'utf8')
71+
builder.set_string_value('user_identity_type_format', None)
72+
builder.set_string_value('user_email_format', six.text_type('{email}'))
73+
builder.set_string_value('user_username_format', None)
74+
builder.set_string_value('user_domain_format', None)
75+
builder.set_string_value('user_given_name_format', six.text_type('{firstName}'))
76+
builder.set_string_value('user_surname_format', six.text_type('{lastName}'))
77+
builder.set_string_value('user_country_code_format', six.text_type('{countryCode}'))
6978
builder.set_string_value('user_identity_type', None)
7079
builder.set_string_value('logger_name', self.name)
7180
host = builder.require_string_value('host')
7281
api_token = builder.require_string_value('api_token')
7382

7483
options = builder.get_options()
7584

85+
OKTAValueFormatter.encoding = options['string_encoding']
86+
self.user_identity_type = user_sync.identity_type.parse_identity_type(options['user_identity_type'])
87+
self.user_identity_type_formatter = OKTAValueFormatter(options['user_identity_type_format'])
88+
self.user_email_formatter = OKTAValueFormatter(options['user_email_format'])
89+
self.user_username_formatter = OKTAValueFormatter(options['user_username_format'])
90+
self.user_domain_formatter = OKTAValueFormatter(options['user_domain_format'])
91+
self.user_given_name_formatter = OKTAValueFormatter(options['user_given_name_format'])
92+
self.user_surname_formatter = OKTAValueFormatter(options['user_surname_format'])
93+
self.user_country_code_formatter = OKTAValueFormatter(options['user_country_code_format'])
94+
7695
self.users_client = None
7796
self.groups_client = None
7897
self.logger = logger = user_sync.connector.helper.create_logger(options)
@@ -167,7 +186,14 @@ def iter_group_members(self, group, filter_string, extended_attributes):
167186
:rtype iterator(str, str)
168187
"""
169188

170-
user_attribute_names = ["firstName", "lastName", "login", "email", "countryCode"]
189+
user_attribute_names = []
190+
user_attribute_names.extend(self.user_given_name_formatter.get_attribute_names())
191+
user_attribute_names.extend(self.user_surname_formatter.get_attribute_names())
192+
user_attribute_names.extend(self.user_country_code_formatter.get_attribute_names())
193+
user_attribute_names.extend(self.user_identity_type_formatter.get_attribute_names())
194+
user_attribute_names.extend(self.user_email_formatter.get_attribute_names())
195+
user_attribute_names.extend(self.user_username_formatter.get_attribute_names())
196+
user_attribute_names.extend(self.user_domain_formatter.get_attribute_names())
171197
extended_attributes = list(set(extended_attributes) - set(user_attribute_names))
172198
user_attribute_names.extend(extended_attributes)
173199

@@ -181,11 +207,6 @@ def iter_group_members(self, group, filter_string, extended_attributes):
181207
raise AssertionException("Okta error querying for group users: %s" % e)
182208
# Filtering users based all_users_filter query in config
183209
for member in self.filter_users(members, filter_string):
184-
profile = member.profile
185-
if not profile.email:
186-
self.logger.warning('No email attribute for login: %s', profile.login)
187-
continue
188-
189210
user = self.convert_user(member, extended_attributes)
190211
if not user:
191212
continue
@@ -194,13 +215,19 @@ def iter_group_members(self, group, filter_string, extended_attributes):
194215
self.logger.warning("No group found for: %s", group)
195216

196217
def convert_user(self, record, extended_attributes):
197-
profile = record.profile
198218

199219
source_attributes = {}
220+
source_attributes['login'] = login = OKTAValueFormatter.get_profile_value(record,'login')
221+
email, last_attribute_name = self.user_email_formatter.generate_value(record)
222+
email = email.strip() if email else None
223+
if not email:
224+
if last_attribute_name is not None:
225+
self.logger.warning('Skipping user with login %s: empty email attribute (%s)', login, last_attribute_name)
226+
return None
200227
user = user_sync.connector.helper.create_blank_user()
201-
202228
source_attributes['id'] = user['uid'] = record.id
203-
source_attributes['email'] = user['email'] = profile.email
229+
source_attributes['email'] = email
230+
user['email'] = email
204231

205232
source_attributes['identity_type'] = user_identity_type = self.user_identity_type
206233
if not user_identity_type:
@@ -209,37 +236,55 @@ def convert_user(self, record, extended_attributes):
209236
try:
210237
user['identity_type'] = user_sync.identity_type.parse_identity_type(user_identity_type)
211238
except AssertionException as e:
212-
self.logger.warning('Skipping user %s: %s', profile.login, e)
239+
self.logger.warning('Skipping user %s: %s', login, e)
213240
return None
214241

215-
source_attributes['login'] = profile.login
216-
217-
user['username'] = ''
218242

219-
if profile.firstName:
220-
source_attributes['firstName'] = user['firstname'] = profile.firstName
221-
else:
222-
source_attributes['firstName'] = None
223-
224-
if profile.lastName:
225-
source_attributes['lastName'] = user['lastname'] = profile.lastName
226-
else:
227-
source_attributes['lastName'] = None
228243

229-
if profile.countryCode:
230-
source_attributes['countryCode'] = profile.countryCode
231-
user['country'] = profile.countryCode.upper()
244+
username, last_attribute_name = self.user_username_formatter.generate_value(record)
245+
username = username.strip() if username else None
246+
source_attributes['username'] = username
247+
if username:
248+
user['username'] = username
232249
else:
233-
source_attributes['countryCode'] = None
234-
235-
if extended_attributes:
250+
if last_attribute_name:
251+
self.logger.warning('No username attribute (%s) for user with login: %s, default to email (%s)',
252+
last_attribute_name, login, email)
253+
user['username'] = email
254+
255+
domain, last_attribute_name = self.user_domain_formatter.generate_value(record)
256+
domain = domain.strip() if domain else None
257+
source_attributes['domain'] = domain
258+
if domain:
259+
user['domain'] = domain
260+
elif username != email:
261+
user['domain'] = email[email.find('@') + 1:]
262+
elif last_attribute_name:
263+
self.logger.warning('No domain attribute (%s) for user with login: %s', last_attribute_name, login)
264+
265+
first_name_value, last_attribute_name = self.user_given_name_formatter.generate_value(record)
266+
source_attributes['firstName'] = first_name_value
267+
if first_name_value is not None:
268+
user['firstname'] = first_name_value
269+
elif last_attribute_name:
270+
self.logger.warning('No given name attribute (%s) for user with login: %s', last_attribute_name, login)
271+
last_name_value, last_attribute_name = self.user_surname_formatter.generate_value(record)
272+
source_attributes['lastName'] = last_name_value
273+
if last_name_value is not None:
274+
user['lastname'] = last_name_value
275+
elif last_attribute_name:
276+
self.logger.warning('No last name attribute (%s) for user with login: %s', last_attribute_name, login)
277+
country_value, last_attribute_name = self.user_country_code_formatter.generate_value(record)
278+
source_attributes['c'] = country_value
279+
if country_value is not None:
280+
user['country'] = country_value.upper()
281+
elif last_attribute_name:
282+
self.logger.warning('No country code attribute (%s) for user with login: %s', last_attribute_name, login)
283+
284+
if extended_attributes is not None:
236285
for extended_attribute in extended_attributes:
237-
if extended_attribute not in source_attributes:
238-
if hasattr(profile, extended_attribute):
239-
extended_attribute_value = getattr(profile, extended_attribute)
240-
source_attributes[extended_attribute] = extended_attribute_value
241-
else:
242-
source_attributes[extended_attribute] = None
286+
extended_attribute_value = OKTAValueFormatter.get_profile_value(record, extended_attribute)
287+
source_attributes[extended_attribute] = extended_attribute_value
243288

244289
user['source_attributes'] = source_attributes.copy()
245290
return user
@@ -273,6 +318,27 @@ def filter_users(self, users, filter_string):
273318

274319

275320
class OKTAValueFormatter(object):
321+
encoding = 'utf8'
322+
323+
def __init__(self, string_format):
324+
"""
325+
The format string must be a unicode or ascii string: see notes above about being careful in Py2!
326+
"""
327+
if string_format is None:
328+
attribute_names = []
329+
else:
330+
string_format = six.text_type(string_format) # force unicode so attribute values are unicode
331+
formatter = string.Formatter()
332+
attribute_names = [six.text_type(item[1]) for item in formatter.parse(string_format) if item[1]]
333+
self.string_format = string_format
334+
self.attribute_names = attribute_names
335+
336+
def get_attribute_names(self):
337+
"""
338+
:rtype list(str)
339+
"""
340+
return self.attribute_names
341+
276342
@staticmethod
277343
def get_extended_attribute_dict(attributes):
278344

@@ -282,3 +348,38 @@ def get_extended_attribute_dict(attributes):
282348
attr_dict.update({attribute: str})
283349

284350
return attr_dict
351+
352+
def generate_value(self, record):
353+
"""
354+
:type record: dict
355+
:rtype (unicode, unicode)
356+
"""
357+
result = None
358+
attribute_name = None
359+
if self.string_format is not None:
360+
values = {}
361+
for attribute_name in self.attribute_names:
362+
value = self.get_profile_value(record, attribute_name)
363+
if value is None:
364+
values = None
365+
break
366+
values[attribute_name] = value
367+
if values is not None:
368+
result = self.string_format.format(**values)
369+
return result, attribute_name
370+
371+
@classmethod
372+
def get_profile_value(cls, record, attribute_name):
373+
"""
374+
The attribute value type must be decodable (str in py2, bytes in py3)
375+
:type record: okta.models.user.User
376+
:type attribute_name: unicode
377+
"""
378+
if hasattr(record.profile, attribute_name):
379+
attribute_values = getattr(record.profile,attribute_name)
380+
if attribute_values:
381+
try:
382+
return attribute_values.decode(cls.encoding)
383+
except UnicodeError as e:
384+
raise AssertionException("Encoding error in value of attribute '%s': %s" % (attribute_name, e))
385+
return None

0 commit comments

Comments
 (0)