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