Trac, ACLs and OpenLDAP

I'm not a developer, but I like Trac. I like its wiki syntax, and the way you can create a simple, effective ticketing system without a billion options you'll never use anyway. I like how you can disable whole chunks of the system - like the code repository subsystem, which I don't have a use for. It's easy to modify (it's python, after all) and has a billion plugins available.

What we were looking for was a system that does wiki, ticketing, has a way to set ACLs on both, and supports LDAP.

LDAP support for Trac is twofold: first of all, you need to have authentication. Trac doesn't do LDAP auth; Apache does, however. For this, you'll need mod_ldap and mod_authnz_ldap. In your Apache vhost configuration, you'll want to put this for your Trac:

        <Location "/login">
                AuthType Basic
                AuthName "Trac"
                AuthBasicProvider ldap
                AuthLDAPURL "ldaps://1.2.3.4:636/ou=users,dc=company,dc=local?uid?sub?(objectClass=MyUserClass)"
                AuthzLDAPAuthoritative Off
                AuthLDAPGroupAttribute memberUid
                AuthLDAPGroupAttributeIsDN off
                require valid-user
        </Location>

The code speaks for itself: you'll be authenticating against LDAP server 1.2.3.4, using secure LDAP (SSL) on port 636, looking for users in ou=users,dc=company,dc=local, comparing the UID with what the user provided, and only objects of class MyUserClass are being allowed. For most uses, this objectClass will be posixAccount. The AuthLDAPGroupAttributeIsDN setting is set to "off"; if we're using posixGroups, the groups only list UIDs. If your LDAP groups are of objectClass groupOfNames, you'll want this to be on.

So now we can authenticate to Trac via Apache and LDAP. But this only gives us access to the builtin "anonymous" and "authenticated" groups; there's still no link between LDAP groups and Trac groups. Cue the Trac LDAP Plugin: this should map LDAP groups to Trac.

This LDAP Plugin has some minor issues: it doesn't want to work on Trac 0.12, which is the most recent version, so just doing "easy_install ldapplugin" will pull in Trac 0.11 as a dependency. To get it working anyway, a two-line patch is needed:

$ svn co http://trac-hacks.org/svn/ldapplugin/0.11/ ldapplugin

Edit the setup.py and change this:

description = 'LDAP extensions for Trac 0.11',
(...)
install_requires = [ 'Trac>=0.11', 'Trac<0.12' ],

... into this:

description = 'LDAP extensions for Trac 0.12',
(...)
install_requires = [ 'Trac>=0.12', 'Trac<0.13' ],

Next, edit the ldapplugin/api.py file:

--- ldapplugin/api.py
+++ ldapplugin/api.py
@@ -62,7 +62,7 @@
         self._ldapcfg = {}
         for name,value in self.config.options('ldap'):
             if name in LDAP_DIRECTORY_PARAMS:
-                self._ldapcfg[name] = value
+                self._ldapcfg[str(name)] = value
         # user entry local cache
         self._cache = {}
         # max time to live for a cache entry
@@ -178,7 +178,7 @@
         self._ldapcfg = {}
         for name,value in self.config.options('ldap'):
             if name in LDAP_DIRECTORY_PARAMS:
-                self._ldapcfg[name] = value
+                self._ldapcfg[str(name)] = value
         # user entry local cache
         self._cache = {}
         # max time to live for a cache entry

Next is to install it:

$ python ./setup.py install

And voilĂ . You have a proper LdapPlugin for Trac 0.12. Now we have to set it up in trac.ini:

[components]
ldapplugin.api.ldappermissiongroupprovider = enabled
ldapplugin.api.ldappermissionstore = enabled

[ldap]
basedn = dc=company,dc=local
bind_passwd = myverysecurepassword
bind_user = cn=proxy,dc=company,dc=local
enable = true
group_bind = true
group_rdn = ou=groups
groupmember = memberUid
groupname = posixGroup
groupmemberisdn = false
host = 1.2.3.4
port = 636
use_tls = true
user_rdn = ou=users
uidattr = cn

The LDAP options deserve a closer look: lots of LDAP servers do not allow inspecting the tree anonymously (which is a sane approach; would you want strangers to nose through your address books?). So you define group_bind, create a proxy user, and tell Trac to use that user/password to get its info. The same distinction between groupOfNames and posixGroup appears here: usually you'll want to use posixGroup, in which case the group members are not in DN format, and the membership attribute is called memberUID. In case of a groupOfNames, group members are in DN format, and the groupmember attribute is simply called "member".

Now you can assign Trac permissions to LDAP users! Use trac-admin to do this, and keep in mind that LDAP groups are prefixed with an "@" to make the distinction with local groups:

$ trac-admin /path/to/my/project
Welcome to trac-admin 0.12
Interactive Trac administration console.
Copyright (c) 2003-2010 Edgewall Software

Type:  '?' or 'help' for help on commands.

Trac [/path/to/my/project]> permission add @administrators TRAC_ADMIN

You can give different groups different Trac permissions, just like you'd do with local groups. But then the last requirement...

One of the (shipped-by-default) plugins is the Trac Fine Grained Permissions system, which allows you to set ACLs on (amongst others) wiki pages. It uses an svn-style authz file for permissions, and allows wildcards. Lovely. However, this system only looks at internal groups! No integration whatsoever. Sounds bad? It is. And since this plugin is part of Trac itself (in tracopt - optional, sure, but still inside the Trac egg!) you'll have to build your own Trac. Not like that's so hard, of course.

$ svn co http://svn.edgewall.com/repos/trac/trunk/ trac

Edit the file tracopt/perm/authz_policy.py, and replace the function authz_permissions with the following code:

   def authz_permissions(self, resource_key, username):
        if username and username != 'anonymous':
            valid_users = ['*', 'authenticated', username]
            perms = LdapPermissionGroupProvider(self.env).get_permission_groups(username)
            valid_users += perms
        else:
            valid_users = ['*', 'anonymous']
        for resource_section in self.authz.sections:
            resource_glob = to_unicode(resource_section)
            if '@' not in resource_glob:
                resource_glob += '@*'
            if fnmatch(resource_key, resource_glob):
                section = self.authz[resource_section]
                for who, permissions in section.iteritems():
                    if who in valid_users:
                        if isinstance(permissions, basestring):
                            return [permissions]
                        else:
                            return permissions
                    else:
                        self.log.debug('%s does not match any of valid_users: %s', who, valid_users)
        return None

Install Trac from this tree (python ./setup.py install) and you're set. What does this code do? It tosses out the clunky authz-internal groups (yes, you won't be able to use internal groups anymore for ACLs... but there is no reason why you'd want that) and gets the LDAP groups for the given username via LdapPermissionGroupProvider. Easy. All there is left to do now is configure Trac itself to check for Authz ACLs on objects:

[components]
tracopt.perm.authz_policy.* = enabled

[authz_policy]
authz_file = /path/to/my/project/conf/authzpolicy.conf

[trac]
permission_policies = AuthzPolicy, DefaultPermissionPolicy, LegacyAttachmentPolicy

In authzpolicy.conf, you can then define your ACLs, like for an example when you want all users (including anonymous!) to be able to view the main WikiStart page, and only users in LDAP groups "treasury" and "trustedusers" to access the wiki section "financials", where "treasury" has full wiki rights and "trustedusers" read-only:

[wiki:WikiStart]
* = WIKI_VIEW

[wiki:financials/*]
@treasury = WIKI_ADMIN
@trustedusers = WIKI_VIEW
* =

Restart Trac (or Apache, since you're using Trac via Apache, right?) and there you go... Trac is being fully managed by LDAP.

Enjoy!