Added features for better handling of LDAP groups
authorThierry Florac <thierry.florac@onf.fr>
Thu, 02 Jun 2016 15:38:05 +0200
changeset 16 59c957423fe8
parent 15 f46ed40d999d
child 17 0d6dc6d3b962
Added features for better handling of LDAP groups
src/pyams_ldap/interfaces/__init__.py
src/pyams_ldap/plugin.py
src/pyams_ldap/zmi/plugin.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,
--- 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
--- 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