Skip to content

Commit b7f03ca

Browse files
Merge pull request #532 from adobe-dmeservices/feature/adobe-console-connector
New Feature: Adobe Admin Console connector as Directory Source
2 parents c078384 + df3cdb5 commit b7f03ca

File tree

8 files changed

+378
-39
lines changed

8 files changed

+378
-39
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# This is a sample configuration file for the Adobe-Console connector.
2+
#
3+
# umapi (user management api) is a network protocol served by Adobe that
4+
# provides management of users in Adobe-hosted enterprise organizations.
5+
#
6+
# This sample file contains all of the settable options for this protocol.
7+
# All of the settings here can be changed. It is recommended
8+
# that you make a copy of this file and edit that to match your configuration.
9+
# While you are at it, you will likely want to remove a lot of this commentary,
10+
# in order to enhance the readability of your file.
11+
12+
# (optional) UMAPI server settings (defaults as shown)
13+
# The host and endpoint settings specify the Adobe endpoints which
14+
# host the UMAPI services and those which provide authorization.
15+
# The timeout and retries settings control how much delay (in seconds)
16+
# can be tolerated in server responses, and also how many times a request
17+
# that fails due to server timeout or server throttling will be retried.
18+
# You will *never* need to alter these settings unless you are provided
19+
# alternate values by Adobe as part of a support engagement. It is
20+
# highly recommended that you leave these values commented out
21+
# so that the default values are guaranteed to be used.
22+
server:
23+
#host: usermanagement.adobe.io
24+
#endpoint: /v2/usermanagement
25+
#ims_host: ims-na1.adobelogin.com
26+
#ims_endpoint_jwt: /ims/exchange/jwt
27+
#timeout: 120
28+
#retries: 3
29+
30+
# (required) integration settings
31+
# You must specify all five of these settings. Consult the
32+
# Adobe UMAPI documentation and the Adobe I/O Console to determine
33+
# the correct settings for your enterprise organization.
34+
# [NOTE: the priv_key_path setting can be an absolute or relative pathname;
35+
# if relative, it is interpreted relative to this configuration file.]
36+
integration:
37+
org_id: "Org ID goes here"
38+
api_key: "API key goes here"
39+
client_secret: "Client secret goes here"
40+
tech_acct: "Tech account ID goes here"
41+
priv_key_path: "private.key"
42+
43+
# (optional) As an alternative to priv_key_path, you can place the private key
44+
# data directly in this file. To do this, remove the priv_key_path entry above
45+
# and uncomment the following entry. Replace the sample data with the data
46+
# from your private key file (which will be much longer).
47+
#priv_key_data: |
48+
# -----BEGIN RSA PRIVATE KEY-----
49+
# MIIf74jfd84oAgEA6brj4uZ2f1Nkf84j843jfjjJGHYJ8756GHHGGz7jLyZWSscH
50+
# CoifurKJY763GHKL98mJGYxWSBvhlWskdjdatagoeshere986fKFUNGd74kdfuEH
51+
# -----END RSA PRIVATE KEY-----
52+
53+
# (optional) You can store credentials in the operating system credential store
54+
# (Windows Credential Manager, Mac Keychain, Linux Freedesktop Secret Service
55+
# or KWallet - these will be built into the Linux distribution).
56+
# To use this feature, uncomment the following entries and remove the
57+
# api_key, client_secret, and priv_key_data above.
58+
# The actual credential values are placed in the credential store with the
59+
# username as the org_id value, and the key name (perhaps called internet
60+
# or network address) as one of the values below.
61+
#secure_api_key_key: umapi_api_key
62+
#secure_client_secret_key: umapi_client_secret
63+
#secure_priv_key_data_key: umapi_private_key_data
64+
# Note: the Windows credential store generally can't store data as large as a private
65+
# key, so the recommended path for securing your private key on windows is given next.
66+
67+
# (optional): You can secure your private key data by encrypting it, as with
68+
# openssl pkcs8 -in private.key -topk8 -v2 des3 -out private-encrypted.key
69+
# which prompts for a passphrase and creates a passphrase-encrypted file in PKCS#8 format.
70+
# Having done this, you can use the setting priv_key_pass to specify the passphrase needed
71+
# by User Sync to decrypt the private key file (or private key data), as in:
72+
#priv_key_pass: "my passphrase for my private key"
73+
# For better security, you should save your passphrase into the secure credential store
74+
# on your platform (username = your org ID, service/internet address = "umapi_private_key_passphrase")
75+
# and then uncomment this setting:
76+
#secure_priv_key_pass_key: umapi_private_key_passphrase
77+
78+
# (optional) identity_type_filter (default value is all)
79+
# By default, connector will automatically load users from all identity type to be load as directory users.
80+
# When specify with one of the following value (adobeID, enterpriseID, federatedID)
81+
# the connector will automatically filter users by the specified identity type.
82+
identity_type_filter: all
83+

examples/config files - basic/user-sync-config.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ directory_users:
154154
# (optional) okta (no default value)
155155
# okta is a 3rd party federation provider compatible with Adobe Enterprise Federated ID.
156156
# See https://developer.okta.com/ for Okta developer information.
157-
# okta: "connector-okta.ytml"
157+
# okta: "connector-okta.yml"
158+
159+
# (optional) Adobe console
160+
# Query users from an Adobe organization to use as an identity source
161+
# See user manual for more inforation
162+
# adobe_console: connector-adobe-console.yml
158163

159164
# (optional) groups (no default value)
160165
# The groups setting specifies how groups in the enterprise directory map

user_sync/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def main():
124124
help='specify a connector to use; default is LDAP (or CSV if --users file is specified)',
125125
cls=user_sync.cli.OptionMulti,
126126
type=list,
127-
metavar='ldap|okta|csv [path-to-file.csv]')
127+
metavar='ldap|okta|csv|adobe_console [path-to-file.csv]')
128128
@click.option('--process-groups/--no-process-groups', default=None,
129129
help='if membership in mapped groups differs between the enterprise directory and Adobe sides, '
130130
'the group membership is updated on the Adobe side so that the memberships in mapped '

user_sync/config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def load_invocation_options(self):
127127
# --connector
128128
connector_spec = options['connector']
129129
connector_type = user_sync.helper.normalize_string(connector_spec[0])
130-
if connector_type in ["ldap", "okta"]:
130+
if connector_type in ["ldap", "okta", "adobe_console"]:
131131
if len(connector_spec) > 1:
132132
raise AssertionException('Must not specify a file (%s) with connector type %s' %
133133
(connector_spec[0], connector_type))
@@ -312,6 +312,7 @@ def get_directory_connector_configs(self):
312312
connectors_config.get_list('ldap', True)
313313
connectors_config.get_list('csv', True)
314314
connectors_config.get_list('okta', True)
315+
connectors_config.get_list('adobe_console', True)
315316
return connectors_config
316317

317318
def get_directory_connector_options(self, connector_name):
@@ -845,7 +846,8 @@ class ConfigFileLoader:
845846
}
846847

847848
# like ROOT_CONFIG_PATH_KEYS, but for non-root configuration files
848-
SUB_CONFIG_PATH_KEYS = {'/enterprise/priv_key_path': (True, False, None)}
849+
SUB_CONFIG_PATH_KEYS = {'/enterprise/priv_key_path': (True, False, None),
850+
'/integration/priv_key_path': (True, False, None)}
849851

850852
@classmethod
851853
def load_root_config(cls, filename):
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Copyright (c) 2016-2017 Adobe Inc. All rights reserved.
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
21+
import six
22+
import umapi_client
23+
import user_sync.config
24+
import user_sync.connector.helper
25+
import user_sync.helper
26+
import user_sync.identity_type
27+
from user_sync.error import AssertionException
28+
from user_sync.version import __version__ as app_version
29+
from user_sync.connector.umapi_util import make_auth_dict
30+
from user_sync.helper import normalize_string
31+
from user_sync.identity_type import parse_identity_type
32+
33+
34+
def connector_metadata():
35+
metadata = {
36+
'name': AdobeConsoleConnector.name
37+
}
38+
return metadata
39+
40+
41+
def connector_initialize(options):
42+
"""
43+
:type options: dict
44+
"""
45+
state = AdobeConsoleConnector(options)
46+
return state
47+
48+
49+
def connector_load_users_and_groups(state, groups=None, extended_attributes=None, all_users=True):
50+
"""
51+
:type state: OktaDirectoryConnector
52+
:type groups: list(str)
53+
:type extended_attributes: list(str)
54+
:type all_users: bool
55+
:rtype (bool, iterable(dict))
56+
"""
57+
58+
return state.load_users_and_groups(groups or [], extended_attributes or [], all_users)
59+
60+
61+
class AdobeConsoleConnector(object):
62+
name = 'adobe_console'
63+
64+
def __init__(self, caller_options):
65+
66+
caller_config = user_sync.config.DictConfig('<%s configuration>' % self.name, caller_options)
67+
builder = user_sync.config.OptionsBuilder(caller_config)
68+
# Let just ignore this
69+
builder.set_string_value('user_identity_type', None)
70+
builder.set_string_value('identity_type_filter', 'all')
71+
options = builder.get_options()
72+
73+
if not options['identity_type_filter'] == 'all':
74+
try:
75+
options['identity_type_filter'] = parse_identity_type(options['identity_type_filter'])
76+
except Exception as e:
77+
raise AssertionException("Error parsing identity_type_filter option: %s" % e)
78+
self.filter_by_identity_type = options['identity_type_filter']
79+
80+
server_config = caller_config.get_dict_config('server', True)
81+
server_builder = user_sync.config.OptionsBuilder(server_config)
82+
server_builder.set_string_value('host', 'usermanagement.adobe.io')
83+
server_builder.set_string_value('endpoint', '/v2/usermanagement')
84+
server_builder.set_string_value('ims_host', 'ims-na1.adobelogin.com')
85+
server_builder.set_string_value('ims_endpoint_jwt', '/ims/exchange/jwt')
86+
server_builder.set_int_value('timeout', 120)
87+
server_builder.set_int_value('retries', 3)
88+
options['server'] = server_options = server_builder.get_options()
89+
90+
enterprise_config = caller_config.get_dict_config('integration')
91+
integration_builder = user_sync.config.OptionsBuilder(enterprise_config)
92+
integration_builder.require_string_value('org_id')
93+
integration_builder.require_string_value('tech_acct')
94+
options['integration'] = integration_options = integration_builder.get_options()
95+
96+
self.logger = logger = user_sync.connector.helper.create_logger(options)
97+
logger.debug('%s initialized with options: %s', self.name, options)
98+
99+
self.options = options
100+
101+
ims_host = server_options['ims_host']
102+
self.org_id = org_id = integration_options['org_id']
103+
auth_dict = make_auth_dict(self.name, enterprise_config, org_id, integration_options['tech_acct'], logger)
104+
105+
# this check must come after we fetch all the settings
106+
caller_config.report_unused_values(logger)
107+
# open the connection
108+
um_endpoint = "https://" + server_options['host'] + server_options['endpoint']
109+
logger.debug('%s: creating connection for org %s at endpoint %s', self.name, org_id, um_endpoint)
110+
111+
try:
112+
self.connection = umapi_client.Connection(
113+
org_id=org_id,
114+
auth_dict=auth_dict,
115+
ims_host=ims_host,
116+
ims_endpoint_jwt=server_options['ims_endpoint_jwt'],
117+
user_management_endpoint=um_endpoint,
118+
test_mode=False,
119+
user_agent="user-sync/" + app_version,
120+
logger=self.logger,
121+
timeout_seconds=float(server_options['timeout']),
122+
retry_max_attempts=server_options['retries'] + 1,
123+
)
124+
except Exception as e:
125+
raise AssertionException("Connection to org %s at endpoint %s failed: %s" % (org_id, um_endpoint, e))
126+
logger.debug('%s: connection established', self.name)
127+
self.umapi_users = []
128+
self.user_by_usr_key = {}
129+
130+
def load_users_and_groups(self, groups, extended_attributes, all_users):
131+
"""
132+
:type groups: list(str)
133+
:type extended_attributes: list(str)
134+
:type all_users: bool
135+
:rtype (bool, iterable(dict))
136+
"""
137+
138+
if extended_attributes:
139+
self.logger.warning("Extended Attributes is not supported")
140+
141+
# Loading all the groups because UMAPI doesn't support group query. DOH!
142+
self.logger.info('Loading groups...')
143+
umapi_groups = list(self.iter_umapi_groups())
144+
self.logger.info('Loading users...')
145+
146+
# Loading all umapi users based on ID Type first before doing group filtering
147+
filter_by_identity_type = self.filter_by_identity_type
148+
self.load_umapi_users(identity_type=filter_by_identity_type)
149+
150+
grouped_user_records = {}
151+
for group in groups:
152+
group_users_count = 0
153+
if group in umapi_groups:
154+
grouped_users = self.iter_group_members(group)
155+
for user_key in grouped_users:
156+
if user_key in self.user_by_usr_key:
157+
user = self.user_by_usr_key[user_key]
158+
user['groups'].append(group)
159+
self.user_by_usr_key[user_key] = grouped_user_records[user_key] = user
160+
group_users_count = group_users_count + 1
161+
self.logger.debug('Count of users in group "%s": %d', group, group_users_count)
162+
else:
163+
self.logger.warning("No group found for: %s", group)
164+
if all_users:
165+
self.logger.debug('Count of users in any groups: %d', len(grouped_user_records))
166+
self.logger.debug('Count of users not in any groups: %d',
167+
len(self.user_by_usr_key) - len(grouped_user_records))
168+
return six.itervalues(self.user_by_usr_key)
169+
else:
170+
return six.itervalues(grouped_user_records)
171+
172+
def convert_user(self, record):
173+
174+
source_attributes = {}
175+
user = user_sync.connector.helper.create_blank_user()
176+
user['uid'] = record['username']
177+
source_attributes['email'] = user['email'] = email = record['email']
178+
user_identity_type = record['type']
179+
try:
180+
source_attributes['type'] = user['identity_type'] = user_sync.identity_type.parse_identity_type(
181+
user_identity_type)
182+
except AssertionException as e:
183+
self.logger.warning('Skipping user %s: %s', email, e)
184+
return None
185+
186+
source_attributes['username'] = user['username'] = record['username']
187+
source_attributes['domain'] = user['domain'] = record['domain']
188+
189+
if 'firstname' in record:
190+
firstname = record['firstname']
191+
else:
192+
firstname = None
193+
source_attributes['firstname'] = user['firstname'] = firstname
194+
195+
if 'lastname' in record:
196+
lastname = record['lastname']
197+
else:
198+
lastname = None
199+
source_attributes['lastname'] = user['lastname'] = lastname
200+
201+
source_attributes['country'] = user['country'] = record['country']
202+
203+
user['source_attributes'] = source_attributes.copy()
204+
return user
205+
206+
def iter_umapi_groups(self):
207+
try:
208+
groups = umapi_client.GroupsQuery(self.connection)
209+
for group in groups:
210+
yield group['groupName']
211+
except umapi_client.UnavailableError as e:
212+
raise AssertionException("Error to query groups from Adobe Console: %s" % e)
213+
214+
def iter_group_members(self, group):
215+
umapi_users = self.umapi_users
216+
members = filter(lambda u: ('groups' in u and group in u['groups']), umapi_users)
217+
for member in members:
218+
user_key = self.generate_user_key(member['type'], member['username'], member['domain'])
219+
yield (user_key)
220+
221+
def load_umapi_users(self, identity_type):
222+
try:
223+
u_query = umapi_client.UsersQuery(self.connection)
224+
umapi_users = u_query.all_results()
225+
226+
if not identity_type == 'all':
227+
umapi_users = list(filter(lambda usr: usr['type'] == identity_type, umapi_users))
228+
229+
self.umapi_users = umapi_users
230+
for user in umapi_users:
231+
# Generate unique user key because Username/Email is a bad unique identifier
232+
user_key = self.generate_user_key(user['type'], user['username'], user['domain'])
233+
self.user_by_usr_key[user_key] = self.convert_user(user)
234+
except umapi_client.UnavailableError as e:
235+
raise AssertionException("Error contacting UMAPI server: %s" % e)
236+
237+
def generate_user_key(self, identity_type, username, domain):
238+
return '%s,%s,%s' % (normalize_string(identity_type), normalize_string(username), normalize_string(domain))

0 commit comments

Comments
 (0)