# HG changeset patch # User Thierry Florac # Date 1464874685 -7200 # Node ID 59c957423fe8471de856c6f8480360d1309aa080 # Parent f46ed40d999de03bd5fcc421436419a0736644c3 Added features for better handling of LDAP groups diff -r f46ed40d999d -r 59c957423fe8 src/pyams_ldap/interfaces/__init__.py --- a/src/pyams_ldap/interfaces/__init__.py Mon Jan 18 18:02:35 2016 +0100 +++ b/src/pyams_ldap/interfaces/__init__.py Thu Jun 02 15:38:05 2016 +0200 @@ -14,18 +14,24 @@ # import standard library +import re from ldap3 import SEARCH_SCOPE_BASE_OBJECT, SEARCH_SCOPE_SINGLE_LEVEL, SEARCH_SCOPE_WHOLE_SUBTREE # import interfaces from pyams_security.interfaces import IAuthenticationPlugin, IDirectorySearchPlugin # import packages -from zope.schema import TextLine, Bool, Int, Choice +from zope.interface import Interface, Attribute +from zope.schema import TextLine, Bool, Int, Choice, Dict, List from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm from pyams_ldap import _ +# +# Search scopes vocabulary +# + SEARCH_SCOPES = {SEARCH_SCOPE_BASE_OBJECT: _("Base object"), SEARCH_SCOPE_SINGLE_LEVEL: _("Single level"), SEARCH_SCOPE_WHOLE_SUBTREE: _("Whole subtree")} @@ -33,6 +39,48 @@ SEARCH_SCOPES_VOCABULARY = SimpleVocabulary([SimpleTerm(v, title=t) for v, t in SEARCH_SCOPES.items()]) +# +# Group members query mode +# + +GROUP_MEMBERS_QUERY_MODE = {'group': _("Use group attribute to get members list"), + 'member': _("Use member attribute to get groups list")} + +GROUP_MEMBERS_QUERY_MODE_VOCABULARY = SimpleVocabulary([SimpleTerm(v, title=t) + for v, t in GROUP_MEMBERS_QUERY_MODE.items()]) + + +# +# Group mail mode +# + +GROUP_MAIL_MODE = {'none': _("none (only use members own mail address"), + 'internal': _("Use group internal attribute"), + 'redirect': _("Use another group internal attribute")} + +GROUP_MAIL_MODE_VOCABULARY = SimpleVocabulary([SimpleTerm(v, title=t) + for v, t in GROUP_MAIL_MODE.items()]) + + +class ILDAPBaseInfo(Interface): + """LDAP base entry interface""" + + dn = Attribute("LDAP DN") + + attributes = Attribute("Entry attributes") + + +class ILDAPUserInfo(ILDAPBaseInfo): + """LDAP user entry interface""" + + +class ILDAPGroupInfo(ILDAPBaseInfo): + """LDAP group entry interface""" + + def get_members(self): + """Get all group members""" + + class ILDAPPlugin(IAuthenticationPlugin, IDirectorySearchPlugin): """LDAP authentication plug-in interface""" @@ -101,6 +149,15 @@ required=True, default='{givenName[0]} {sn[0]}') + mail_attribute = TextLine(title=_("Mail attribute"), + description=_("LDAP attribute storing mail address"), + required=True, + default='mail') + + user_extra_attributes = TextLine(title=_("Extra attributes"), + description=_("Comma separated list of additional attributes"), + required=False) + groups_base_dn = TextLine(title=_("Groups base DN"), description=_("Base DN used to search LDAP groups; keep empty to " "disable groups usage"), @@ -111,13 +168,6 @@ default=SEARCH_SCOPE_WHOLE_SUBTREE, required=False) - groups_query = TextLine(title=_("Groups query"), - description=_("Query template used to get principal groups " - "(based on DN and UID attributes called 'dn' " - "and 'login')"), - required=False, - default="(&(objectClass=groupOfUniqueNames)(uniqueMember={dn}))") - group_prefix = TextLine(title=_("Group prefix"), description=_("Prefix used to identify groups"), required=False, @@ -133,6 +183,52 @@ required=True, default='{cn[0]}') + group_members_query_mode = Choice(title=_("Members query mode"), + description=_("Define how groups members are defined"), + vocabulary=GROUP_MEMBERS_QUERY_MODE_VOCABULARY, + default='group') + + groups_query = TextLine(title=_("Groups query"), + description=_("When members are store inside a group attribute, " + "this query template is used to get principal groups " + "(based on DN and UID attributes called 'dn' " + "and 'login')"), + required=False, + default="(&(objectClass=groupOfUniqueNames)(uniqueMember={dn}))") + + group_members_attribute = TextLine(title=_("Group members attribute"), + description=_("When groups members are stored inside a group attribute, " + "this is the attribute name"), + required=False, + default='uniqueMember') + + user_groups_attribute = TextLine(title=_("User groups attribute"), + description=_("When user groups are stored inside a user attribute, " + "this is the attribute name"), + required=False, + default='memberOf') + + group_mail_mode = Choice(title=_("Group mail mode"), + description=_("Define how an email can be sent to group members"), + vocabulary=GROUP_MAIL_MODE_VOCABULARY, + required=True, + default='none') + + group_replace_expression = TextLine(title=_("DN replace expression"), + description=_("In 'redirect' mail mode, specify source and target DN parts, " + "separated by a pipe"), + constraint=re.compile("[a-zA-Z0-9-=,]+|[a-zA-Z0-9-=,]+").match, + default='ou=access|ou=lists') + + group_mail_attribute = TextLine(title=_("Mail attribute"), + description=_("In 'internal' mail mode, specify name of group mail attribute"), + required=True, + default='mail') + + group_extra_attributes = TextLine(title=_("Extra attributes"), + description=_("Comma separated list of additional attributes"), + required=False) + users_select_query = TextLine(title=_("Users select query"), description=_("Query template used to select users"), required=True, diff -r f46ed40d999d -r 59c957423fe8 src/pyams_ldap/plugin.py --- a/src/pyams_ldap/plugin.py Mon Jan 18 18:02:35 2016 +0100 +++ b/src/pyams_ldap/plugin.py Thu Jun 02 15:38:05 2016 +0200 @@ -21,7 +21,8 @@ import re # import interfaces -from pyams_ldap.interfaces import ILDAPPlugin +from pyams_ldap.interfaces import ILDAPPlugin, ILDAPUserInfo, ILDAPGroupInfo +from pyams_mail.interfaces import IPrincipalMailInfo from zope.intid.interfaces import IIntIds # import packages @@ -29,6 +30,7 @@ from persistent import Persistent from pyams_ldap.query import LDAPQuery from pyams_security.principal import PrincipalInfo +from pyams_utils.adapter import adapter_config, ContextAdapter from pyams_utils.registry import query_utility from zope.container.contained import Contained from zope.interface import implementer @@ -78,6 +80,87 @@ auto_bind=True, lazy=False, read_only=True) return conn + +@implementer(ILDAPUserInfo) +class LDAPUserInfo(object): + """LDAP user info""" + + def __init__(self, dn, attributes, plugin=None): + self.dn = dn + self.attributes = attributes + self.plugin = plugin + + +@adapter_config(context=ILDAPUserInfo, provides=IPrincipalMailInfo) +class LDAPUserMailInfoAdapter(ContextAdapter): + """LDAP user mail adapter""" + + def get_addresses(self): + """Get mail address of given user""" + + user = self.context + plugin = user.plugin + + mail = user.attributes.get(plugin.mail_attribute) + if mail: + return {(plugin.title_format.format(**user.attributes), mail[0])} + else: + return set() + + +@implementer(ILDAPGroupInfo) +class LDAPGroupInfo(object): + """LDAP group info""" + + def __init__(self, dn, attributes, plugin=None): + self.dn = dn + self.attributes = attributes + self.plugin = plugin + + def get_members(self, info=True): + return self.plugin.get_members(self, info=info) + + +@adapter_config(context=ILDAPGroupInfo, provides=IPrincipalMailInfo) +class LDAPGroupMailInfoAdapter(ContextAdapter): + """LDAP group mail adapter""" + + def get_addresses(self): + """Get mail address of given group""" + + group = self.context + plugin = group.plugin + + if plugin.group_mail_mode == 'none': + # use members address + for member in plugin.get_members(group, info=False): + mail_info = IPrincipalMailInfo(member, None) + if mail_info is not None: + for address in mail_info.get_addresses(): + yield address + + elif plugin.group_mail_mode == 'internal': + # use group internal attribute + mail = group.attributes.get(plugin.group_mail_attribute) + if mail: + yield plugin.group_title_format(**group.attributes), mail[0] + + else: + # redirect: use internal attribute of another group + source, target = plugin.group_replace_expression.split('|') + target_dn = group.dn.replace(source, target) + conn = plugin.get_connection() + attributes = FORMAT_ATTRIBUTES.findall(plugin.group_title_format) + [plugin.group_mail_attribute] + search = LDAPQuery(target_dn, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes) + result = search.execute(conn) + if not result or len(result) > 1: + raise StopIteration + target_dn, attrs = result[0] + mail = attrs.get(plugin.group_mail_attribute) + if mail: + yield plugin.group_title_format(**attrs), mail[0] + + @implementer(ILDAPPlugin) class LDAPPlugin(Persistent, Contained): """LDAP authentication plug-in""" @@ -100,17 +183,28 @@ pool_lifetime = FieldProperty(ILDAPPlugin['pool_lifetime']) base_dn = FieldProperty(ILDAPPlugin['base_dn']) search_scope = FieldProperty(ILDAPPlugin['search_scope']) + login_attribute = FieldProperty(ILDAPPlugin['login_attribute']) login_query = FieldProperty(ILDAPPlugin['login_query']) uid_attribute = FieldProperty(ILDAPPlugin['uid_attribute']) uid_query = FieldProperty(ILDAPPlugin['uid_query']) title_format = FieldProperty(ILDAPPlugin['title_format']) + mail_attribute = FieldProperty(ILDAPPlugin['mail_attribute']) + user_extra_attributes = FieldProperty(ILDAPPlugin['user_extra_attributes']) + groups_base_dn = FieldProperty(ILDAPPlugin['groups_base_dn']) groups_search_scope = FieldProperty(ILDAPPlugin['groups_search_scope']) - groups_query = FieldProperty(ILDAPPlugin['groups_query']) group_prefix = FieldProperty(ILDAPPlugin['group_prefix']) group_uid_attribute = FieldProperty(ILDAPPlugin['group_uid_attribute']) group_title_format = FieldProperty(ILDAPPlugin['group_title_format']) + group_members_query_mode = FieldProperty(ILDAPPlugin['group_members_query_mode']) + groups_query = FieldProperty(ILDAPPlugin['groups_query']) + group_members_attribute = FieldProperty(ILDAPPlugin['group_members_attribute']) + user_groups_attribute = FieldProperty(ILDAPPlugin['user_groups_attribute']) + group_mail_mode = FieldProperty(ILDAPPlugin['group_mail_mode']) + group_replace_expression = FieldProperty(ILDAPPlugin['group_replace_expression']) + group_mail_attribute = FieldProperty(ILDAPPlugin['group_mail_attribute']) + group_extra_attributes = FieldProperty(ILDAPPlugin['group_extra_attributes']) users_select_query = FieldProperty(ILDAPPlugin['users_select_query']) users_search_query = FieldProperty(ILDAPPlugin['users_search_query']) @@ -213,7 +307,9 @@ conn = self.get_connection() if login.startswith(self.group_prefix + ':'): group_prefix, group_id = login.split(':', 1) - attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format) + attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format) + [self.group_mail_attribute] + if self.group_extra_attributes: + attributes += self.group_extra_attributes.split(',') if self.group_uid_attribute == 'dn': search = LDAPQuery(group_id, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes) else: @@ -222,13 +318,21 @@ if not result or len(result) > 1: return None group_dn, attrs = result[0] - return PrincipalInfo(id='{prefix}:{group_prefix}:{group_id}'.format(prefix=self.prefix, - group_prefix=self.group_prefix, - group_id=group_id), - title=self.group_title_format.format(**attrs), - dn=group_dn) + if info: + return PrincipalInfo(id='{prefix}:{group_prefix}:{group_id}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + group_id=group_id), + title=self.group_title_format.format(**attrs), + dn=group_dn) + else: + attrs.update({'principal_id': '{prefix}:{group_prefix}:{group_id}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + group_id=group_id)}) + return LDAPGroupInfo(group_dn, attrs, self) else: - attributes = FORMAT_ATTRIBUTES.findall(self.title_format) + attributes = FORMAT_ATTRIBUTES.findall(self.title_format) + [self.mail_attribute] + if self.user_extra_attributes: + attributes += self.user_extra_attributes.split(',') if self.uid_attribute == 'dn': search = LDAPQuery(login, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes) else: @@ -237,32 +341,61 @@ if not result or len(result) > 1: return None user_dn, attrs = result[0] - return PrincipalInfo(id='{prefix}:{login}'.format(prefix=self.prefix, - login=login), - title=self.title_format.format(**attrs), - dn=user_dn) + if info: + return PrincipalInfo(id='{prefix}:{login}'.format(prefix=self.prefix, + login=login), + title=self.title_format.format(**attrs), + dn=user_dn) + else: + attrs.update({'principal_id': '{prefix}:{login}'.format(prefix=self.prefix, login=login)}) + return LDAPUserInfo(user_dn, attrs, self) def _get_groups(self, principal): - if not self.groups_base_dn: - raise StopIteration + """Get principal groups""" principal_dn = principal.attributes.get('dn') if principal_dn is None: raise StopIteration - conn = self.get_connection() - attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format) - search = LDAPQuery(self.groups_base_dn, self.groups_query, self.groups_search_scope, attributes) - for group_dn, group_attrs in search.execute(conn, dn=principal_dn): - if self.group_uid_attribute == 'dn': - yield '{prefix}:{group_prefix}:{dn}'.format(prefix=self.prefix, - group_prefix=self.group_prefix, - dn=group_dn) - else: - yield '{prefix}:{group_prefix}:{attr}'.format(prefix=self.prefix, - group_prefix=self.group_prefix, - attr=group_attrs[self.group_uid_attribute]) + if self.group_members_query_mode == 'group': + # group members are defined inside group + if not self.groups_base_dn: + raise StopIteration + conn = self.get_connection() + attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format) + search = LDAPQuery(self.groups_base_dn, self.groups_query, self.groups_search_scope, attributes) + for group_dn, group_attrs in search.execute(conn, dn=principal_dn): + if self.group_uid_attribute == 'dn': + yield '{prefix}:{group_prefix}:{dn}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + dn=group_dn) + else: + yield '{prefix}:{group_prefix}:{attr}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + attr=group_attrs[self.group_uid_attribute]) + else: + # a member defines it's groups + conn = self.get_connection() + attributes = [self.user_groups_attribute] + user_search = LDAPQuery(principal_dn, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes) + for user_dn, user_attrs in user_search.execute(conn): + if self.group_uid_attribute == 'dn': + for group_dn in user_attrs.get(self.user_groups_attribute, ()): + yield '{prefix}:{group_prefix}:{dn}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + dn=group_dn) + else: + attributes = [self.group_uid_attribute] + for group_dn in user_attrs.get(self.user_groups_attribute, ()): + group_search = LDAPQuery(group_dn, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, + attributes) + for group_search_dn, group_search_attrs in group_search.execute(conn): + yield '{prefix}:{group_prefix}:{attr}'.format(prefix=self.prefix, + group_prefix=self.group_prefix, + attr=group_search_attrs[ + self.group_uid_attribute]) @cache_region('short') def get_all_principals(self, principal_id): + """Get all principals (including groups) for given principal ID""" if not self.enabled: return set() principal = self.get_principal(principal_id) @@ -273,6 +406,47 @@ return result return set() + def get_members(self, group, info=True): + """Get all members of given LDAP group as LDAP users""" + if not self.enabled: + return set() + conn = self.get_connection() + if self.group_members_query_mode == 'group': + # group members are defined into group attribute + attributes = [self.group_members_attribute] + user_attributes = FORMAT_ATTRIBUTES.findall(self.title_format) + [self.mail_attribute] + search = LDAPQuery(group.dn, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes) + for group_dn, attrs in search.execute(conn): + for user_dn in attrs.get(self.group_members_attribute): + user_search = LDAPQuery(user_dn, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, user_attributes) + for user_search_dn, user_search_attrs in user_search.execute(conn): + if info: + yield PrincipalInfo(id='{prefix}:{dn}'.format(prefix=self.prefix, + dn=user_search_dn), + title=self.title_format.format(**user_search_attrs), + dn=user_search_dn) + else: + yield LDAPUserInfo(dn=user_search_dn, attributes=user_search_attrs, plugin=self) + else: + # member groups are defined into member attribute + attributes = FORMAT_ATTRIBUTES.findall(self.title_format) + [self.uid_attribute, self.mail_attribute] + search = LDAPQuery(self.base_dn, '({attribute}={{group_dn}})'.format(attribute=self.user_groups_attribute), + self.search_scope, attributes) + for user_dn, user_attrs in search.execute(conn, group_dn=group.dn): + if info: + if self.uid_attribute == 'dn': + yield PrincipalInfo(id='{prefix}:{dn}'.format(prefix=self.prefix, + dn=user_dn), + title=self.title_format.format(**user_attrs), + dn=user_dn) + else: + yield PrincipalInfo(id='{prefix}:{attr}'.format(prefix=self.prefix, + attr=user_attrs[self.uid_attribute][0]), + title=self.title_format.format(**user_attrs), + dn=user_dn) + else: + yield LDAPUserInfo(dn=user_dn, attributes=user_attrs, plugin=self) + def find_principals(self, query): if not self.enabled: raise StopIteration diff -r f46ed40d999d -r 59c957423fe8 src/pyams_ldap/zmi/plugin.py --- a/src/pyams_ldap/zmi/plugin.py Mon Jan 18 18:02:35 2016 +0100 +++ b/src/pyams_ldap/zmi/plugin.py Thu Jun 02 15:38:05 2016 +0200 @@ -137,7 +137,8 @@ tab_label = _("Users schema") legend = None fields = field.Fields(ILDAPPlugin).select('base_dn', 'search_scope', 'login_attribute', 'login_query', - 'uid_attribute', 'uid_query', 'title_format') + 'uid_attribute', 'uid_query', 'title_format', + 'mail_attribute', 'user_extra_attributes') weight = 2 @@ -148,8 +149,12 @@ id = 'ldap_groups_form' tab_label = _("Groups schema") legend = None - fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'groups_query', - 'group_prefix', 'group_uid_attribute', 'group_title_format') + fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'group_prefix', + 'group_uid_attribute', 'group_title_format', + 'group_members_query_mode', 'groups_query', + 'group_members_attribute', 'user_groups_attribute', 'group_mail_mode', + 'group_replace_expression', 'group_mail_attribute', + 'group_extra_attributes') weight = 3 @@ -232,7 +237,8 @@ tab_label = _("Users schema") legend = None fields = field.Fields(ILDAPPlugin).select('base_dn', 'search_scope', 'login_attribute', 'login_query', - 'uid_attribute', 'uid_query', 'title_format') + 'uid_attribute', 'uid_query', 'title_format', + 'mail_attribute', 'user_extra_attributes') weight = 2 @@ -243,8 +249,12 @@ id = 'ldap_groups_form' tab_label = _("Groups schema") legend = None - fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'groups_query', - 'group_prefix', 'group_uid_attribute', 'group_title_format') + fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'group_prefix', + 'group_uid_attribute', 'group_title_format', + 'group_members_query_mode', 'groups_query', + 'group_members_attribute', 'user_groups_attribute', 'group_mail_mode', + 'group_replace_expression', 'group_mail_attribute', + 'group_extra_attributes') weight = 3