Skip to content

Commit a85b517

Browse files
Merge pull request #342 from adorton-adobe/feature/group-creation
Additional group discovery and creation
2 parents 9bb3475 + 4aae87b commit a85b517

File tree

9 files changed

+299
-17
lines changed

9 files changed

+299
-17
lines changed

docs/en/user-manual/advanced_configuration.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,119 @@ In order to use the Okta connector, you will need to specify the `--connector ok
681681

682682
Okta sync can use extended groups, attributes and after-mapping hooks. The names of extended attributes must be valid Okta profile fields.
683683

684+
## Additional Group Options
685+
686+
It is possible for the User Sync Tool to sync group relationships that
687+
are not explicitly mapped out in `user-sync-config.yml`. Any LDAP group
688+
that a user belongs to directly can be mapped and targeted to an Adobe
689+
profile or user group using the `additional_groups` configuration
690+
option.
691+
692+
* Additional groups are identified with a regular expression
693+
* The name of the mapped Adobe group can be customized with a regular
694+
expression substitution string.
695+
696+
Possible use cases:
697+
698+
* Metadata such as department, employee type, etc
699+
* ACL groups for [Adobe Experience Manager](https://www.adobe.com/marketing/experience-manager.html)
700+
* Special-case group, role or profile assignment
701+
702+
Note: This feature only works with the LDAP connector at this time.
703+
704+
### Additional Group Rules
705+
706+
`additional_groups` is defined in `user-sync-config.yml` in the `groups`
707+
object. It specifies a list of rules to identify and filter groups
708+
present in the `memberOf` LDAP attribute, as well as rules that govern
709+
how corresponding Adobe groups should be named. Groups that are
710+
discovered with this feature will be added to a user's list of
711+
targeted Adobe groups.
712+
713+
### Additional Group Example
714+
715+
Suppose an Adobe Experience Manager customer would like
716+
to sync all AEM users to the admin console. They define a group
717+
mapping in `user-sync-config.yml` to map the LDAP group `AEM-USERS` to
718+
the Adobe group `Adobe Experience Manager`.
719+
720+
```yaml
721+
- directory_group: "AEM-USERS"
722+
adobe_groups:
723+
- "Adobe Experience Manager"
724+
```
725+
726+
This example company's AEM users fall into two broad categories -
727+
authors and publishers. These users already belong to LDAP groups that
728+
correspond to each role - `AEM-ACL-AUTHORS` and `AEM-ACL-PUBLISHERS`,
729+
respectively. Suppose this company wishes to assign users to these
730+
additional groups when syncing users. Assuming group membership
731+
information can be found in the `memberOf` user attribute, they can
732+
leverage the `additional_groups` config option.
733+
734+
```yaml
735+
directory_users:
736+
# ... additional directory config options
737+
groups:
738+
# ... group mappings, etc
739+
additional_groups:
740+
- source: "AEM-ACL-(.+)"
741+
target: "AEM-(\\1)"
742+
```
743+
744+
`additional_groups` contains a list of additional group rules. `source`
745+
is a regular expression that identifies the group. Only groups that
746+
match a `source` regex will be included. `target` is a regex
747+
substitution string that allows group names to be renamed. In this
748+
case, any group beginning with `AEM-ACL` will be renamed to `AEM-[role]`.
749+
Each rule is executed on the list of groups a user directly belongs to.
750+
In this example, authors and publishers are added to their respective
751+
Adobe user group (`AEM-AUTHORS` or `AEM-PUBLISHERS`).
752+
753+
Note: The company in this example can also add mappings for authors
754+
and publishers to the group mapping in `user-sync-config.yml`. The
755+
advantage to using the additional groups mechanism is that it
756+
will apply dynamically to any LDAP group that matches the regex
757+
`AEM-ACL-(.+)`. If additional AEM roles are introduced, they will
758+
be included in sync as long as they follow that naming convention -
759+
no configuration change would be needed.
760+
761+
## Automatic Group Creation
762+
763+
The User Sync Tool can be configured to automatically create targeted
764+
Adobe user groups that do not already exist. This can be used in
765+
conjunction with the additional groups functionality detailed in the
766+
previous section, but it also applies to Adobe groups targeted in
767+
the group mapping as well as the extension config.
768+
769+
`group_sync_options` is defined in the `directory_users` section in
770+
`user-sync-config.yml`. It contains an object that currently has just
771+
one key - `auto_create`. `auto_create` is boolean and is `False` by
772+
default.
773+
774+
To enable dynamic group creation, set `auto_create` to `True`:
775+
776+
```yaml
777+
directory_users:
778+
# ... additional directory config options
779+
group_sync_options:
780+
auto_create: True
781+
```
782+
783+
With auto create enabled, a given Adobe group will be created if the
784+
following conditions are true:
785+
786+
1. Group is targeted for at least one user
787+
2. Group does not currently exist
788+
3. The `--process-groups` command argument is set (or the equivalent
789+
invocation option)
790+
791+
New groups are always created as user groups. The UMAPI does not
792+
support product profile creation, so the Sync Tool can't create them.
793+
If the Sync Tool is configured to target a misspelled profile name, or
794+
a profile that doesn't exist, it will automatically create a user group
795+
with the specified name.
796+
684797
---
685798

686799
[Previous Section](usage_scenarios.md) \| [Next Section](deployment_best_practices.md)

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

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,10 @@ directory_users:
167167
# is specified as a list of entries, each of which has a directory_group
168168
# setting (whose value is a single directory group) and an adobe_groups
169169
# setting (whose value is a list of 0 or more product configuration and
170-
# user groups). All of the values in the adobe_groups settings must
171-
# match the name of product configurations and user groups which have
172-
# already been created on the Adobe side. (In this example, we pretend
173-
# that "Acrobat DC Pro" is a product configuration and "Copy Editors"
174-
# is a user group that the you have already created. Possibly
175-
# the "Copy Editors" user group has been assigned access to appropriate
176-
# Adobe products, such as InDesign and InCopy.)
170+
# user groups). In this example, imagine that "Acrobat DC Pro" is a
171+
# product configuration and "Copy Editors" is a user group, and that
172+
# the "Copy Editors" user group will be assigned access to appropriate
173+
# Adobe products, such as InDesign and InCopy.
177174
# [You will need to edit or remove these examples.]
178175
- directory_group: "Finance"
179176
adobe_groups:
@@ -186,6 +183,46 @@ directory_users:
186183
- "Copy Editors"
187184
- "Acrobat DC Pro"
188185

186+
# (optional) additional_groups (no default value)
187+
# People who use their directory groups for ACLs on the Adobe side
188+
# often have a very large number of groups that they want mapped
189+
# over to (user) groups on the Adobe side. To avoid having to
190+
# specify those groups statically in their config file, and to
191+
# update their config file when they change, they can instead
192+
# use a naming convention for the groups and specify that here.
193+
# The value of this attribute is a mapping from Python regular expressions
194+
# that specify directory groups of interest to Python replacement expressions
195+
# that specify how to construct the name of the target Adobe group
196+
# that the directory group should be mapped to. If a value is
197+
# provided, then all users who are (directly) in groups whose
198+
# CN matches one of the source regular expressions will be put in a user group
199+
# on the Adobe side whose name is given by the target replacement expression.
200+
# The simple example here (which should be removed) maps all the
201+
# groups that start with "ACL-" or end with "-ACL" to an Adobe
202+
# group that starts with "ACL-Grp-".
203+
# (All of these regular expressions must match the entire group name.
204+
# For details on Python regular expression matching and replacement,
205+
# see https://docs.python.org/howto/regex.html )
206+
# additional_groups:
207+
# - source: "ACL-(.+)"
208+
# target: "ACL-Grp-(\1)"
209+
# - source: "(.+)-ACL"
210+
# target: "ACL-Grp-(\1)"
211+
212+
# (optional) group_sync_options (default: all options false)
213+
# Options that govern the automatic creation and/or deletion of Adobe user groups
214+
# auto_create:
215+
# Automatically create target groups that do not exist. Non-existent groups
216+
# will *always* be created as Adobe groups. If targeting product profiles, they
217+
# must always be created manually in the Admin Console.
218+
# NOTE: auto_create applies to any targeted Adobe group that doesn't exist.
219+
# This includes groups targeted through group mapping, extension config,
220+
# and the additional_groups functionality.
221+
# The --process-groups command argument or equivalent invocation setting must
222+
# be enabled for groups to be auto-created
223+
# group_sync_options:
224+
# auto_create: False
225+
189226
# The limits section provides processing limits which can help ensure that
190227
# User Sync jobs do not exceed expected guardrails in their operation
191228
limits:

examples/config files - basic/3 connector-ldap.yml

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,17 @@ all_users_filter: "(&(objectClass=user)(objectCategory=person)(!(userAccountCont
7272
group_filter_format: "(&(|(objectCategory=group)(objectClass=groupOfNames)(objectClass=posixGroup))(cn={group}))"
7373

7474
# (optional) group_member_filter_format (default value given below)
75-
# group_users_filter specifies the query used to find all members of a group,
75+
# group_member_filter_format specifies the query used to find all members of a group,
7676
# where the string {group_dn} is replaced with the group distinguished name.
7777
# The default value just finds users who are immediate members of the group,
7878
# not those who are "indirectly" members by virtue of membership in a group
7979
# that is contained in the group. If you want indirect containment, then
8080
# use this value instead of the default:
8181
# group_member_filter_format: "(memberOf:1.2.840.113556.1.4.1941:={group_dn})"
8282
group_member_filter_format: "(memberOf={group_dn})"
83+
# Note that this filter is &-combined with the all_users_filter so that
84+
# only users that would be selected by that filter will be returned as
85+
# members of the given group.
8386

8487
# (optional) string_encoding (default value given below)
8588
# string_encoding specifies the Unicode string encoding used by the directory.
@@ -172,14 +175,9 @@ user_email_format: "{mail}"
172175
# are already pre-defined attribute names that are used for these fields:
173176
# - the Adobe first name is set from the LDAP "givenName" attribute
174177
# - the Adobe last name is set from the LDAP "sn" (surname) attribute
175-
# - the Adobe country is set from the LDAP "country" attribute
178+
# - the Adobe country is set from the LDAP "c" (country) attribute
176179
# If you need to override these values on the Adobe side, you can use the
177180
# custom extension mechanism (see the docs) to compute and set field values
178-
# by combining these and any other custom attributes needed. Seed the
181+
# by combining these and any other custom attributes needed. See the
179182
# User Sync documentation for full details.
180-
#
181-
# Finally, some LDAP systems use uids to identify groups, and place users in
182-
# groups via uid rather than name. The User Sync implementation always reads
183-
# the uid attribute on all objects if the directory provides one, so it is
184-
# able to handle directories which function in this way even though the
185-
# configuration files always specify groups by name.
183+

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
'pyldap==2.4.45',
5353
'PyYAML',
5454
'six',
55-
'umapi-client>=2.10',
55+
'umapi-client>=2.11',
5656
],
5757
extras_require={
5858
':sys_platform=="linux" or sys_platform=="linux2"':[

user_sync/app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ def begin_work(config_loader):
307307
directory_connector_options['user_identity_type'] = rule_config['new_account_type']
308308
directory_connector.initialize(directory_connector_options)
309309

310+
additional_group_filters = None
311+
additional_groups = rule_config.get('additional_groups', None)
312+
if additional_groups and isinstance(additional_groups, list):
313+
additional_group_filters = [r['source'] for r in additional_groups]
314+
315+
directory_connector.state.additional_group_filters = additional_group_filters
316+
310317
primary_name = '.primary' if secondary_umapi_configs else ''
311318
umapi_primary_connector = user_sync.connector.umapi.UmapiConnector(primary_name, primary_umapi_config)
312319
umapi_other_connectors = {}

user_sync/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ def get_rule_options(self):
444444
options.update(self.invocation_options)
445445

446446
# process directory configuration options
447+
new_account_type = None
447448
directory_config = self.main_config.get_dict_config('directory_users', True)
448449
if directory_config:
449450
# account type
@@ -457,6 +458,15 @@ def get_rule_options(self):
457458
default_country_code = directory_config.get_string('default_country_code', True)
458459
if default_country_code:
459460
options['default_country_code'] = default_country_code
461+
additional_groups = directory_config.get_list('additional_groups', True) or []
462+
additional_groups = [{'source': re.compile(r['source']), 'target': r['target']} for r in additional_groups]
463+
options['additional_groups'] = additional_groups
464+
sync_options = directory_config.get_dict_config('group_sync_options', True)
465+
if sync_options:
466+
options['auto_create'] = sync_options.get_bool('auto_create', True)
467+
if not new_account_type:
468+
new_account_type = user_sync.identity_type.ENTERPRISE_IDENTITY_TYPE
469+
self.logger.debug("Using default for new_account_type: %s", new_account_type)
460470

461471
# process exclusion configuration options
462472
adobe_config = self.main_config.get_dict_config('adobe_users', True)

user_sync/connector/directory_ldap.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import user_sync.error
2929
import user_sync.identity_type
3030
from user_sync.error import AssertionException
31+
from ldap import dn
3132

3233

3334
def connector_metadata():
@@ -121,6 +122,7 @@ def __init__(self, caller_options):
121122
self.connection = connection
122123
logger.debug('Connected')
123124
self.user_by_dn = {}
125+
self.additional_group_filters = None
124126

125127
def load_users_and_groups(self, groups, extended_attributes, all_users):
126128
"""
@@ -209,6 +211,7 @@ def iter_users(self, users_filter, extended_attributes):
209211
user_attribute_names.extend(self.user_email_formatter.get_attribute_names())
210212
user_attribute_names.extend(self.user_username_formatter.get_attribute_names())
211213
user_attribute_names.extend(self.user_domain_formatter.get_attribute_names())
214+
user_attribute_names.append('memberOf')
212215

213216
extended_attributes = [six.text_type(attr) for attr in extended_attributes]
214217
extended_attributes = list(set(extended_attributes) - set(user_attribute_names))
@@ -289,6 +292,18 @@ def iter_users(self, users_filter, extended_attributes):
289292
elif last_attribute_name:
290293
self.logger.warning('No country code attribute (%s) for user with dn: %s', last_attribute_name, dn)
291294

295+
uid_value = LDAPValueFormatter.get_attribute_value(record, six.text_type('uid'))
296+
source_attributes['uid'] = uid_value
297+
298+
user['member_groups'] = []
299+
if self.additional_group_filters:
300+
member_groups = []
301+
for f in self.additional_group_filters:
302+
for g in self.get_member_groups(record):
303+
if f.match(g) and g not in member_groups:
304+
member_groups.append(g)
305+
user['member_groups'] = member_groups
306+
292307
if extended_attributes is not None:
293308
for extended_attribute in extended_attributes:
294309
extended_attribute_value = LDAPValueFormatter.get_attribute_value(record, extended_attribute)
@@ -301,6 +316,36 @@ def iter_users(self, users_filter, extended_attributes):
301316

302317
yield (dn, user)
303318

319+
def get_member_groups(self, user):
320+
"""
321+
Get a list of member group common names for user
322+
Assumes groups are contained in attribute memberOf
323+
:param user:
324+
:return:
325+
"""
326+
group_names = []
327+
groups = LDAPValueFormatter.get_attribute_value(user, 'memberOf')
328+
for group_dn in map(dn.str2dn, groups):
329+
group_cn = self.get_cn_from_dn(group_dn)
330+
if group_cn:
331+
group_names.append(group_cn)
332+
return group_names
333+
334+
@staticmethod
335+
def get_cn_from_dn(group_dn):
336+
"""
337+
Take a DN parsed by ldap.dn.str2dn and locate and return the common name
338+
Returns None if no common name is found
339+
If common name is complex (e.g. cn=Bob Jones+email=bob.jones@example.com) then first part of CN is returned
340+
:param group_dn:
341+
:return:
342+
"""
343+
for rdn in group_dn:
344+
for rdn_part in rdn:
345+
if rdn_part[0].lower() == 'cn':
346+
return rdn_part[1]
347+
return None
348+
304349
def iter_search_result(self, base_dn, scope, filter_string, attributes):
305350
"""
306351
type: filter_string: str

user_sync/connector/umapi.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,32 @@ def iter_users(self):
146146
except umapi_client.UnavailableError as e:
147147
raise AssertionException("Error contacting UMAPI server: %s" % e)
148148

149+
def get_groups(self):
150+
return list(self.iter_groups())
151+
152+
def iter_groups(self):
153+
try:
154+
for g in umapi_client.GroupsQuery(self.connection):
155+
yield g
156+
except umapi_client.UnavailableError as e:
157+
raise AssertionException("Error contacting UMAPI server: %s" % e)
158+
159+
def get_user_groups(self):
160+
return list(self.iter_user_groups())
161+
162+
def iter_user_groups(self):
163+
try:
164+
for g in umapi_client.UserGroupsQuery(self.connection):
165+
yield g
166+
except umapi_client.UnavailableError as e:
167+
raise AssertionException("Error contacting UMAPI server: %s" % e)
168+
169+
def create_group(self, name):
170+
if name:
171+
group = umapi_client.UserGroupAction(group_name=name)
172+
group.create(description="Automatically created by User Sync Tool")
173+
return self.connection.execute_single(group)
174+
149175
def get_action_manager(self):
150176
return self.action_manager
151177

0 commit comments

Comments
 (0)