source: ldapplugin/0.11/ldapplugin/api.py

Last change on this file was 16527, checked in by Ryan J Ollos, 6 years ago

Fix indentation

  • Property svn:eol-style set to native
File size: 26.2 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# LDAP permission extensions for Trac
4#
5# Copyright (C) 2003-2006 Edgewall Software
6# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
7# All rights reserved.
8#
9# This software is licensed as described in the file COPYING, which
10# you should have received as part of this distribution. The terms
11# are also available at http://trac.edgewall.com/license.html.
12#
13# This software consists of voluntary contributions made by many
14# individuals. For the exact contribution history, see the revision
15# history and logs, available at http://projects.edgewall.com/trac/.
16#
17# Warning: this plug in has not been extensively tested, and may have security
18# issues. Do not use this plugin on production servers where security is
19# a concern.
20# Requires Python-LDAP, available from http://python-ldap.sourceforge.net
21#
22
23import re
24import time
25import ldap
26
27from trac.core import *
28from trac.perm import IPermissionGroupProvider, IPermissionStore
29from trac.config import _TRUE_VALUES
30
31LDAP_MODULE_CONFIG = [ 'enable', 'permfilter',
32                       'global_perms', 'manage_groups'
33                       'cache_ttl', 'cache_size',
34                       'group_bind', 'store_bind',
35                       'user_rdn', 'group_rdn' ]
36
37LDAP_DIRECTORY_PARAMS = [ 'host', 'port', 'use_tls', 'basedn',
38                          'bind_user', 'bind_passwd',
39                          'groupname', 'groupmember', 'groupmemberisdn',
40                          'groupattr', 'uidattr', 'permattr']
41
42GROUP_PREFIX = '@'
43
44# regular expression to explode a DN into a (attr, rdn, basedn)
45DN_RE = re.compile(r'^(?P<attr>.+?)=(?P<rdn>.+?),(?P<base>.+)$')
46
47class LdapPermissionGroupProvider(Component):
48    """
49    Provides permission groups from a LDAP directory
50    """
51    implements(IPermissionGroupProvider)
52
53    def __init__(self, ldap=None):
54        # looks for groups only if LDAP support is enabled
55        self.enabled = self.config.getbool('ldap', 'enable')
56        if not self.enabled:
57            return
58        self.util = LdapUtil(self.config)
59        # LDAP connection
60        self._ldap = ldap
61        # LDAP connection config
62        self._ldapcfg = {}
63        for name,value in self.config.options('ldap'):
64            if name in LDAP_DIRECTORY_PARAMS:
65                self._ldapcfg[name] = value
66        # user entry local cache
67        self._cache = {}
68        # max time to live for a cache entry
69        self._cache_ttl = int(self.config.get('ldap', 'cache_ttl', str(15*60)))
70        # max cache entries
71        self._cache_size = min(25, int(self.config.get('ldap', 'cache_size',
72                                                       '100')))
73
74    # IPermissionProvider interface
75
76    def get_permission_groups(self, username):
77        """Return a list of names of the groups that the user with the
78        specified name is a member of."""
79
80        # anonymous and authenticated groups are set with the default provider
81        groups = []
82        if not self.enabled:
83            return groups
84
85        # stores the current time for the request (used for the cache)
86        current_time = time.time()
87
88        # test for if username in the cache
89        if username in self._cache:
90            # cache hit
91            lut, groups = self._cache[username]
92
93            # ensures that the cache is not too old
94            if current_time < lut+self._cache_ttl:
95                # sources the cache
96                # cache lut is not updated to ensure
97                # it is refreshed on a regular basis
98                self.env.log.debug('cached (%s): %s' % \
99                                   (username, ','.join(groups)))
100                return groups
101
102        # cache miss (either not found or too old)
103        if not self._ldap:
104            # new LDAP connection
105            bind = self.config.getbool('ldap', 'group_bind')
106            self._ldap = LdapConnection(self.env.log, bind, **self._ldapcfg)
107
108        # retrieves the user groups from LDAP
109        ldapgroups = self._get_user_groups(username)
110        # if some group is found
111        if ldapgroups:
112            # tests for cache size
113            if len(self._cache) >= self._cache_size:
114                # the cache is becoming too large, discards
115                # the less recently uses entries
116                cache_keys = self._cache.keys()
117                cache_keys.sort(lambda x,y: cmp(self._cache[x][0],
118                                                self._cache[y][0]))
119                # discards the 5% oldest
120                old_keys = cache_keys[:(5*self._cache_size)/100]
121                for k in old_keys:
122                    del self._cache[k]
123        else:
124            # deletes the cache if there's no group for this user
125            # for debug, until a failed LDAP connection returns an error...
126            if username in self._cache:
127                del self._cache[username]
128
129        # updates the cache
130        self._cache[username] = [current_time, ldapgroups]
131
132        # returns the user groups
133        groups.extend(ldapgroups)
134        if groups:
135            self.env.log.debug('groups: ' + ','.join(groups))
136
137        return groups
138
139    def flush_cache(self, username=None):
140        """Invalidate the entire cache or a named entry"""
141        if username is None:
142            self._cache = {}
143        elif self._cache.has_key(username):
144            del self._cache[username]
145
146    # Private API
147
148    def _get_user_groups(self, username):
149        """Returns a list of all groups a user belongs to"""
150        ldap_groups = self._ldap.get_groups()
151        groups = []
152        for group in ldap_groups:
153            if self._ldap.is_in_group(self.util.user_attrdn(username), group):
154                m = DN_RE.search(group)
155                if m:
156                    groupname = GROUP_PREFIX + m.group('rdn')
157                    if groupname not in groups:
158                        groups.append(groupname)
159        return groups
160
161class LdapPermissionStore(Component):
162    """
163    Stores and manages permissions with a LDAP directory backend
164    """
165    implements(IPermissionStore)
166
167    group_providers = ExtensionPoint(IPermissionGroupProvider)
168
169    def __init__(self, ldap=None):
170        # looks for groups only if LDAP support is enabled
171        self.enabled = self.config.getbool('ldap', 'enable')
172        if not self.enabled:
173            return
174        self.util = LdapUtil(self.config)
175        # LDAP connection
176        self._ldap = ldap
177        # LDAP connection config
178        self._ldapcfg = {}
179        for name,value in self.config.options('ldap'):
180            if name in LDAP_DIRECTORY_PARAMS:
181                self._ldapcfg[name] = value
182        # user entry local cache
183        self._cache = {}
184        # max time to live for a cache entry
185        self._cache_ttl = int(self.config.get('ldap', 'cache_ttl', str(15*60)))
186        # max cache entries
187        cache_size = self.config.get('ldap', 'cache_size', '100')
188        self._cache_size = min(25, int(cache_size))
189        # environment name
190        envpath = self.env.path.replace('\\','/')
191        self.env_name = envpath[1+envpath.rfind('/'):]
192        # use directory-wide permissions
193        self.global_perms = self.config.getbool('ldap', 'global_perms')
194        self.manage_groups = self.config.getbool('ldap', 'manage_groups')
195
196    # IPermissionStore interface
197
198    def get_user_permissions(self, username):
199        """Retrieves the user permissions from the LDAP directory"""
200        if not self.enabled:
201            raise TracError("LdapPermissionStore is not enabled")
202        actions = self._get_cache_actions(username)
203        if not actions:
204            users = [username]
205            for provider in self.group_providers:
206                users += list(provider.get_permission_groups(username))
207            for user in users:
208                uid = self.util.create_dn(user)
209                for action in self._get_permissions(uid):
210                    if action not in actions:
211                        actions.append(action)
212
213            self.env.log.debug('new: %s' % actions)
214            self._update_cache_actions(username, actions)
215        perms = {}
216        for action in actions:
217            perms[action] = True
218        return perms
219
220    def get_users_with_permissions(self, permissions):
221        """Retrieve a list of users that have any of the specified permissions.
222        """
223        users = set([u[0] for u in self.env.get_known_users()])
224        result = set()
225        for user in users:
226            userperms = self.get_user_permissions(user)
227            for group in permissions:
228                if group in userperms:
229                    result.add(user)
230        return list(result)
231
232    def get_all_permissions(self):
233        """Retrieve the permissions for all users from the LDAP directory"""
234        # do not use the cache as this method is only used for administration
235        # tasks, not for runtime
236        if not self.enabled:
237            raise TracError("LdapPermissionStore is not enabled")
238        perms = []
239        filterstr = self.config.get('ldap', 'permfilter', 'objectclass=*')
240        basedn = self.config.get('ldap','basedn','').encode('ascii')
241        self._openldap()
242        dns = self._ldap.get_dn(basedn, filterstr.encode('ascii'))
243        permusers = []
244        for dn in dns:
245            user = self.util.extract_user_from_dn(dn)
246            if not user or user in permusers: continue
247            permusers.append(user)
248            self.log.debug("permission for %s (%s)" % (user, dn))
249            actions = self._ldap.get_attribute(dn, self._ldap.permattr)
250            for action in actions:
251                xaction = self._extract_action(action)
252                if not xaction:
253                    continue
254                perms.append((user, xaction))
255            if self.manage_groups:
256                for provider in self.group_providers:
257                    if isinstance(provider, LdapPermissionGroupProvider):
258                        for group in provider.get_permission_groups(user):
259                            perms.append((user, group))
260        return perms
261
262    def grant_permission(self, username, action):
263        """Store the new permission for the user in the LDAP directory"""
264        if not self.enabled:
265            raise TracError("LdapPermissionStore is not enabled")
266        if self.manage_groups and self.util.is_group(action):
267            self._flush_group_cache(username)
268            self._add_user_to_group(username.encode('ascii'), action)
269            return
270        uid = self.util.create_dn(username.encode('ascii'))
271        try:
272            permlist = self._get_permissions(uid)
273            action = action.encode('ascii')
274            if action not in permlist:
275                xaction = self._build_action(action)
276                self._ldap.add_attribute(uid, self._ldap.permattr, xaction)
277            if self.util.is_group(username):
278                # flush the cache as group dependencies are not known
279                self.flush_cache()
280            else:
281                self.flush_cache(username)
282                self._add_cache_actions(username, [action])
283        except ldap.LDAPError, e:
284            raise TracError, "Unable to grant permission %s to %s: %s" \
285                             % (action, username, e[0]['desc'])
286
287    def revoke_permission(self, username, action):
288        """Remove the permission for the user from the LDAP directory"""
289        if not self.enabled:
290            raise TracError("LdapPermissionStore is not enabled")
291        if self.manage_groups and self.util.is_group(action):
292            self._flush_group_cache(username)
293            self._remove_user_from_group(username.encode('ascii'), action)
294            return
295        uid = self.util.create_dn(username.encode('ascii'))
296        try:
297            permlist = self._get_permissions(uid)
298            if action in permlist:
299                action = action.encode('ascii')
300                xaction = self._build_action(action)
301                self._ldap.delete_attribute(uid, self._ldap.permattr, xaction)
302                if self.util.is_group(username):
303                    # flush the cache as group dependencies are not known
304                    self.flush_cache()
305                else:
306                    self.flush_cache(username)
307                    self._del_cache_actions(username, [action])
308        except ldap.LDAPError, e:
309            kind = self.global_perms and 'global' or 'project'
310            raise TracError, "Unable to revoke %s permission %s from %s: %s" \
311                             % (kind, action, username, e[0]['desc'])
312
313    # Private implementation
314
315    def _openldap(self):
316        """Open a new connection to the LDAP directory"""
317        if self._ldap is None:
318            bind = self.config.getbool('ldap', 'store_bind')
319            self._ldap = LdapConnection(self.env.log, bind, **self._ldapcfg)
320
321    def _get_permissions(self, uid):
322        """Retrieves the permissions from the LDAP directory"""
323        self._openldap()
324        actions = self._ldap.get_attribute(uid, self._ldap.permattr)
325        perms = []
326        for action in actions:
327            if action not in perms:
328                xaction = self._extract_action(action)
329                if xaction:
330                    perms.append(xaction)
331        return perms
332
333    def _extract_action(self, action):
334        """Filters the actions (global or per-project action)"""
335        items = action.split(':')
336        if len(items) == 1:
337            # no environment, consider global
338            return action
339        (name, xaction) = items
340        if name == self.env_name:
341            # environment, check it
342            return xaction
343        return None
344
345    def _build_action(self, action):
346        """Creates a global or per-project LDAP action"""
347        if self.global_perms:
348            return action
349        return "%s:%s" % (self.env_name, action)
350
351    def _add_user_to_group(self, user, group):
352        groupdn = self.util.create_dn(group)
353        userdn = self.util.create_dn(user)
354        self._openldap()
355        try:
356            self._ldap.add_attribute(groupdn, self._ldap.groupmember, userdn)
357            self.log.info("user %s added to group %s" % (user, group))
358        except ldap.TYPE_OR_VALUE_EXISTS, e:
359            # already in group, can safely ignore
360            self.log.debug("user %s already member of %s" % (user, group))
361            return
362        except ldap.LDAPError, e:
363            raise TracError, e[0]['desc']
364
365    def _remove_user_from_group(self, user, group):
366        groupdn = self.util.create_dn(group)
367        userdn = self.util.create_dn(user)
368        self._openldap()
369        try:
370            self._ldap.delete_attribute(groupdn, self._ldap.groupmember,
371                                        userdn)
372            self.log.info("user %s removed from group %s" % (user, group))
373        except ldap.OBJECT_CLASS_VIOLATION, e:
374            # probable cause is an empty group
375            raise TracError, "Ldap error (group %s would be emptied?)" % group
376        except ldap.LDAPError, e:
377            raise TracError, e[0]['desc']
378
379    def _get_cache_actions(self, username):
380        """Retrieves the user permissions from the cache, if any"""
381        if username in self._cache:
382            lut, actions = self._cache[username]
383            if time.time() < lut+self._cache_ttl:
384                self.env.log.debug('cached (%s): %s' % \
385                                   (username, ','.join(actions)))
386                return actions
387        return []
388
389    def _add_cache_actions(self, username, newactions):
390        """Add new user actions into the cache"""
391        self._cleanup_cache()
392        if username in self._cache:
393            lut, actions = self._cache[username]
394            for action in newactions:
395                if action not in actions:
396                    actions.append(action)
397            self._cache[username] = [time.time(), actions]
398        else:
399            self._cache[username] = [time.time(), newactions]
400
401    def _del_cache_actions(self, username, delactions):
402        """Remove user actions from the cache"""
403        if not username in self._cache:
404            return
405        lut, actions = self._cache[username]
406        newactions = []
407        for action in actions:
408            if action not in delactions:
409                newactions.append(action)
410        if len(newactions) == 0:
411            del self._cache[username]
412        else:
413            self._cache[username] = [time.time(), newactions]
414
415    def _update_cache_actions(self, username, actions):
416        """Set the cache entry for the user with the new actions"""
417        # if not action, delete the cache entry
418        if len(actions) == 0:
419            if username in self._cache:
420                del self._cache[username]
421            return
422        self._cleanup_cache()
423        # overwrite the cache entry with the new actions
424        self._cache[username] = [time.time(), actions]
425
426    def _cleanup_cache(self):
427        """Make sure the cache is not full or discard oldest entries"""
428        # if cache is full, removes the LRU entries
429        if len(self._cache) >= self._cache_size:
430            cache_keys = self._cache.keys()
431            cache_keys.sort(lambda x,y: cmp(self._cache[x][0],
432                                            self._cache[y][0]))
433            old_keys = cache_keys[:(5*self._cache_size)/100]
434            self.log.info("flushing %d cache entries" % len(old_keys))
435            for k in old_keys:
436                del self._cache[k]
437
438    def flush_cache(self, username=None):
439        """Delete all entries in the cache"""
440        if username is None:
441            self._cache = {}
442        elif self._cache.has_key(username):
443            del self._cache[username]
444        # we also need to flush the LDAP permission group provider
445        self._flush_group_cache(username)
446
447    def _flush_group_cache(self, username=None):
448        """Flush the group cache (if in use)"""
449        if self.manage_groups:
450            for provider in self.group_providers:
451                if isinstance(provider, LdapPermissionGroupProvider):
452                    provider.flush_cache(username)
453
454class LdapUtil(object):
455    """Utilities for LDAP data management"""
456
457    def __init__(self, config):
458        for k, default in [('groupattr', 'cn'),
459                           ('uidattr', 'uid'),
460                           ('basedn', None),
461                           ('user_rdn', None),
462                           ('group_rdn', None)]:
463            v = config.get('ldap', k, default)
464            if v: v = v.encode('ascii').lower()
465            self.__setattr__(k, v)
466
467    def is_group(self, username):
468        return username.startswith(GROUP_PREFIX)
469
470    def create_dn(self, username):
471        """Create a user or group LDAP DN from his/its name"""
472        if username.startswith(GROUP_PREFIX):
473            return self.group_attrdn(username[len(GROUP_PREFIX):])
474        else:
475            return self.user_attrdn(username)
476
477    def group_attrdn(self, group):
478        """Build the dn for a group"""
479        if self.group_rdn:
480            return "%s=%s,%s,%s" % \
481                   (self.groupattr, group, self.group_rdn, self.basedn)
482        else:
483            return "%s=%s,%s" % (self.groupattr, group, self.basedn)
484
485    def user_attrdn(self, user):
486        """Build the dn for a user"""
487        if self.user_rdn:
488            return "%s=%s,%s,%s" % \
489                   (self.uidattr, user, self.user_rdn, self.basedn)
490        else:
491            return "%s=%s,%s" % (self.uidattr, user, self.basedn)
492
493    def extract_user_from_dn(self, dn):
494        m = DN_RE.search(dn)
495        if m:
496            sub = m.group('base').lower()
497            basednlen = len(self.basedn)
498            if sub[len(sub)-basednlen:].lower() != self.basedn:
499                return None
500            rdn = sub[:-basednlen-1]
501            if rdn == self.group_rdn:
502                if m.group('attr').lower() == self.groupattr:
503                    return GROUP_PREFIX + m.group('rdn')
504            elif rdn == self.user_rdn:
505                if m.group('attr').lower() == self.uidattr:
506                    return m.group('rdn')
507        return None
508
509class LdapConnection(object):
510    """
511    Wrapper class for the LDAP directory
512    Use only synchronous LDAP calls
513    """
514
515    _BOOL_VAL = ['groupmemberisdn', 'use_tls']
516    _INT_VAL  = ['port']
517
518    def __init__(self, log, bind=False, **ldap):
519        self.log = log
520        self.bind = bind
521        self.host = 'localhost'
522        self.port = None
523        self.groupname = 'groupofnames'
524        self.groupmember = 'member'
525        self.groupattr = 'cn'
526        self.uidattr = 'uid'
527        self.permattr = 'tracperm'
528        self.bind_user = None
529        self.bind_passwd = None
530        self.basedn = None
531        self.groupmemberisdn = True
532        self.use_tls = False
533        for k, v in ldap.items():
534            if k in LdapConnection._BOOL_VAL:
535                self.__setattr__(k, v.lower() in _TRUE_VALUES)
536            elif k in LdapConnection._INT_VAL:
537                self.__setattr__(k, int(v))
538            else:
539                if isinstance(v, unicode):
540                    v = v.encode('ascii')
541                self.__setattr__(k, v)
542        if self.basedn is None:
543            raise TracError, "No basedn is defined"
544        if self.port is None:
545            self.port = self.use_tls and 636 or 389
546
547    def close(self):
548        """Close the connection with the LDAP directory"""
549        self._ds.unbind_s()
550        self._ds = None
551
552    def get_groups(self):
553        """Return a list of available group dns"""
554        groups = self.get_dn(self.basedn, 'objectclass=' + self.groupname)
555        return groups
556
557    def is_in_group(self, userdn, groupdn):
558        """Tell whether the uid is member of the group"""
559        if self.groupmemberisdn:
560            udn = userdn
561        else:
562            m = re.match('[^=]+=([^,]+)', userdn)
563            if m is None:
564                self.log.warn('Malformed userdn: %s' % userdn)
565                return False
566            udn = m.group(1)
567        for attempt in range(2):
568            cr = self._compare(groupdn, self.groupmember, udn)
569            if self._ds:
570                return cr
571        return False
572
573    def get_dn(self, basedn, filterstr):
574        """Return a list of dns that satisfy the LDAP filter"""
575        dns = []
576        for attempt in range(2):
577            sr = self._search(basedn, filterstr, ['dn'], ldap.SCOPE_SUBTREE)
578            if sr:
579                for (dn, attrs) in sr:
580                    dns.append(dn)
581                break
582            if self._ds:
583                break
584        return dns
585
586    def get_attribute(self, dn, attr):
587        """Return the values of the attribute of the dn entry"""
588        attributes = [ attr ]
589        (filt, base) = dn.split(',', 1)
590        values = []
591        for attempt in range(2):
592            sr = self._search(base, filterstr=filt, attributes=attributes)
593            if sr:
594                for (dn, attrs) in sr:
595                    if attrs.has_key(attr):
596                        values = attrs[attr]
597                break
598            if self._ds:
599                break
600        return values
601
602    def add_attribute(self, dn, attr, value):
603        """Add a new value to the attribute of the dn entry"""
604        try:
605            if not self.__dict__.has_key('_ds') or not self.__dict__['_ds']:
606                self._open()
607            self._ds.modify_s(dn, [(ldap.MOD_ADD, attr, value)])
608        except ldap.LDAPError, e:
609            self.log.error("unable to add attribute '%s' to uid '%s': %s" %
610                           (attr, dn, e[0]['desc']))
611            self._ds = False
612            raise e
613
614    def delete_attribute(self, dn, attr, value):
615        """Remove all attributes that match the value from the dn entry"""
616        try:
617            if not self.__dict__.has_key('_ds') or not self.__dict__['_ds']:
618                self._open()
619            self._ds.modify_s(dn, [(ldap.MOD_DELETE, attr, value)])
620        except ldap.LDAPError, e:
621            self.log.error("unable to remove attribute '%s' from uid '%s': %s" %
622                           (attr, dn, e[0]['desc']))
623            self._ds = False
624            raise e
625
626    def _open(self):
627        """Open and optionnally bind a new connection to the LDAP directory"""
628        try:
629            if self.use_tls:
630                ldap.set_option(ldap.OPT_REFERRALS, 0)
631                ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, \
632                                ldap.OPT_X_TLS_NEVER)
633                protocol = 'ldaps'
634            else:
635                protocol = 'ldap'
636            self._ds = ldap.initialize('%s://%s:%d/' % \
637                                       (protocol, self.host, self.port))
638            self._ds.protocol_version = ldap.VERSION3
639            if self.bind:
640                if not self.bind_user:
641                    raise TracError("Bind enabled but credentials not defined")
642                head = self.bind_user[:self.bind_user.find(',')]
643                if ( head.find('=') == -1 ):
644                    self.bind_user = '%s=%s' % (self.uidattr, self.bind_user)
645                self._ds.simple_bind_s(self.bind_user, self.bind_passwd)
646            else:
647                self._ds.simple_bind_s()
648        except ldap.LDAPError, e:
649            self._ds = None
650            if self.bind_user:
651                self.log.warn("Unable to open LDAP with user %s" % \
652                              self.bind_user)
653            raise TracError("Unable to open LDAP cnx: %s" % e[0]['desc'])
654
655    def _search(self, basedn, filterstr='(objectclass=*)', attributes=None,
656                scope=ldap.SCOPE_ONELEVEL):
657        """Search the LDAP directory"""
658        try:
659            if not self.__dict__.has_key('_ds') or not self.__dict__['_ds']:
660                self._open()
661            sr = self._ds.search_s(basedn, scope, filterstr, attributes)
662            return sr
663        except ldap.NO_SUCH_OBJECT, e:
664            self.log.warn("LDAP error: %s (%s)", e[0]['desc'], basedn)
665            return False;
666        except ldap.LDAPError, e:
667            self.log.error("LDAP error: %s", e[0]['desc'])
668            self._ds = False
669            return False;
670
671    def _compare(self, dn, attribute, value):
672        """Compare the attribute value of a LDAP DN"""
673        try:
674            if not self.__dict__.has_key('_ds') or not self.__dict__['_ds']:
675                self._open()
676            cr = self._ds.compare_s(dn, attribute, value)
677            return cr
678        except ldap.NO_SUCH_OBJECT, e:
679            self.log.warn("LDAP error: %s (%s)", e[0]['desc'], dn)
680            return False;
681        except ldap.LDAPError, e:
682            self.log.error("LDAP error: %s", e[0]['desc'])
683            self._ds = False
684            return False
Note: See TracBrowser for help on using the repository browser.