Add support for special $user repositories
authorSteve McIntyre <smcintyre@aminocom.com>
Tue, 20 Oct 2009 13:49:04 +0000 (14:49 +0100)
committerSteve McIntyre <smcintyre@aminocom.com>
Tue, 20 Oct 2009 13:49:04 +0000 (14:49 +0100)
Extra syntax in the config file ($user) will now denote wildcard-style
user repositories. This allows us to add support for users to have
their own public repos without having to do tedious admin work for all
of them.

This involves changes to:

 * access.py to add lookups for $user access on top of normal access;
   $user is treated as a wildcard where necessary when doing
   filesystem path lookups, but used for permission checking too: the
   magic user "$user" can now be added to the members list in
   configuration groups.

 * gitweb.py to add code to deal with the $user wildcards when
   generating the description and project list files that gitweb
   uses. Where we can determine the user's credentials locally, use
   full names. Some code refactoring to fit with existing code
   better. Also: don't assume that close and rename are safe: force
   flush and fsync too

 * example.conf to add entries for user directories. Show how the user
   directories can be made to work

example.conf
gitosis/access.py
gitosis/gitweb.py

index 87bd822..30b8ec6 100644 (file)
@@ -47,6 +47,39 @@ owner = John Doe
 ## Allow git-daemon to publish this repository.
 daemon = yes
 
+# Special group that adds support for repositories of the form
+# user/<user>/<foo>.git. Otherwise admins would have to add specific
+# entries for every single user repo, and that would quickly become an
+# admin nightmare!
+#
+# The following config group will allow *write* access to
+# user/<user>/<foo>.git for the owner ($user) and the @admins group
+#
+# If you want to allow a specific user (Bob) to have write access to
+# a specific user repo (belonging to Alice), you will need to add a
+# specific group describing Alice's user repo and list Bob in the
+# members field there as normal. That will supplement the normal
+# access from the wild-card group entries. Sorry, this *will* require
+# admin work.
+[group user-write]
+members = $user @admins
+writable = users/$user/*
+
+# And this will add read-only access to the same set of modules for
+# anyone in the @amino group
+[group other-user-readonly]
+members = @amino
+readonly = users/*/*
+
+# Finally, add gitweb and git:// access to the user repositories too.
+# The $user in the "repo" line is important, as that's how we look up
+# the username when generating the Description and Owner fields in
+# gitweb output. If we can find user details for $user, we will
+# substitute their name in the Description field, replacing
+# '$username'
+[repo users/$user/*]
+description = Public repository for $username
+
 [gitweb]
 ## Where to make gitweb link to as it's "home location".
 ## NOT YET IMPLEMENTED.
index c95c842..267910b 100644 (file)
@@ -3,6 +3,37 @@ from ConfigParser import NoSectionError, NoOptionError
 
 from gitosis import group
 
+def pathMatch(path, repo):
+    """
+    Compare path and repos, dealing with wildcards where appropriate
+    """
+
+    log = logging.getLogger('gitosis.access.pathMatch')
+
+    pathsplit = path.split(os.sep)
+    reposplit = repo.split(os.sep)
+
+    # Must have the same number of components
+    if len(pathsplit) != len(reposplit):
+        return None
+
+    # Compare each component individually. Both '*' and '$user' count
+    # as simple wildcards here; '$user' will already have been
+    # replaced by the current username before we get here on the
+    # second ($user) pass
+    for i in range(len(pathsplit)):
+        if (
+            pathsplit[i] != reposplit[i] and 
+            reposplit[i] != '*' and
+            reposplit[i] != '$user'
+           ):
+            log.debug('path match %d failed; %s != %s and no wildcard match' % (i, pathsplit[i], reposplit[i]))
+            return None
+
+    log.debug('paths %s and %s match ok!' % (path, repo))
+    return path
+
+
 def haveAccess(config, user, mode, path):
     """
     Map request for write access to allowed path.
@@ -43,16 +74,17 @@ def haveAccess(config, user, mode, path):
 
         mapping = None
 
-        if path in repos:
-            log.debug(
-                'Access ok for %(user)r as %(mode)r on %(path)r'
-                % dict(
-                user=user,
-                mode=mode,
-                path=path,
-                ))
-            mapping = path
-        else:
+        for repo in repos:
+            if pathMatch(path, repo):
+                log.debug(
+                    'Access ok for %(user)r as %(mode)r on %(path)r'
+                    % dict(
+                    user=user,
+                    mode=mode,
+                    path=path,
+                    ))
+                mapping = path
+        if mapping is None:
             try:
                 mapping = config.get('group %s' % groupname,
                                      'map %s %s' % (mode, path))
@@ -86,3 +118,64 @@ def haveAccess(config, user, mode, path):
                 path=mapping,
                 ))
             return (prefix, mapping)
+
+    # Now see if we have access using the $user rules
+    for groupname in group.getMembership(config=config, user='$user'):
+        try:
+            repos = config.get('group %s' % groupname, mode)
+        except (NoSectionError, NoOptionError):
+            repos = []
+        else:
+            repos = repos.split()
+
+        mapping = None
+
+        for repo in repos:
+            # Substitute in our own username to replace $user now
+            repo = repo.replace('$user', user)
+            log.debug('map-> user %s has %s access to repo %s' % (user, mode, repo))
+
+            if pathMatch(path, repo):
+                log.debug(
+                    'Access ok for $user %(user)r as %(mode)r on %(path)r'
+                    % dict(
+                    user=user,
+                    mode=mode,
+                    path=path,
+                    ))
+                mapping = path
+            else:
+                try:
+                    mapping = config.get('group %s' % groupname,
+                                         'map %s %s' % (mode, path))
+                except (NoSectionError, NoOptionError):
+                    pass
+                else:
+                    log.debug(
+                        'Access ok for %(user)r as %(mode)r on %(path)r=%(mapping)r'
+                        % dict(
+                        user='$user',
+                        mode=mode,
+                        path=path,
+                        mapping=mapping,
+                        ))
+
+        if mapping is not None:
+            prefix = None
+            try:
+                prefix = config.get(
+                    'group %s' % groupname, 'repositories')
+            except (NoSectionError, NoOptionError):
+                try:
+                    prefix = config.get('gitosis', 'repositories')
+                except (NoSectionError, NoOptionError):
+                    prefix = 'repositories'
+
+            log.debug(
+                'Using prefix %(prefix)r for %(path)r'
+                % dict(
+                prefix=prefix,
+                path=mapping,
+                ))
+            return (prefix, mapping)
+
index b4b538b..8886a3e 100644 (file)
@@ -25,7 +25,7 @@ To plug this into ``gitweb``, you have two choices.
    isolates the changes a bit more nicely. Recommended.
 """
 
-import os, urllib, logging
+import os, urllib, logging, glob, pwd
 
 from ConfigParser import NoSectionError, NoOptionError
 
@@ -37,6 +37,47 @@ def _escape_filename(s):
     s = s.replace('"', '\\"')
     return s
 
+def _generate_user_module_entry(name, repositories, fp):
+
+    log = logging.getLogger('gitosis.gitweb._generate_user_module_entry')
+    remove = repositories + os.sep
+    userpos = -1
+
+    # Deal with wildcards in the path
+    if -1 != name.find('$user'):
+        namesplit = name.split(os.sep)
+        # Work out which of the path components matches '$user' so
+        # we can use it later
+        for i in range(len(namesplit)):
+            if namesplit[i] == '$user':
+                userpos = i
+                break
+        name = name.replace('$user', '*')
+
+    if -1 != name.find('*'):
+        for entry in glob.glob(os.path.join(repositories, name)):
+            entry = entry.replace(remove, '')
+            # Now, if we have a userpos we can use that to work
+            # out who owns this repository
+            if userpos != -1:
+                namesplit = entry.split(os.sep)
+                owner = namesplit[userpos]
+                try:
+                    # Let's see if we can get a full name rather
+                    # than just username here
+                    pwnam = pwd.getpwnam(owner)
+                    owner = pwnam[4]
+                except:
+                    pass
+            else:
+                owner = ""
+            response = [entry]
+            response.append(owner)
+            line = ' '.join([urllib.quote_plus(s) for s in response])
+            print >>fp, line
+            log.debug('Found entry %s' % line)
+
+
 def generate_project_list_fp(config, fp):
     """
     Generate projects list for ``gitweb``.
@@ -74,6 +115,11 @@ def generate_project_list_fp(config, fp):
 
         name, = l
 
+        # Special handling for $user and wildcard repositories
+        if -1 != name.find('$user') or -1 != name.find('*'):
+            _generate_user_module_entry(name, repositories, fp)
+            continue
+
         if not os.path.exists(os.path.join(repositories, name)):
             namedotgit = '%s.git' % name
             if os.path.exists(os.path.join(repositories, namedotgit)):
@@ -112,8 +158,70 @@ def generate_project_list(config, path):
     finally:
         f.close()
 
+    f.flush()
+    os.fsync(f.fileno())
     os.rename(tmp, path)
 
+def _write_description(repositories, name, description):
+
+    log = logging.getLogger('gitosis.gitweb.write_description')
+
+    path = os.path.join(
+        repositories,
+        name,
+        'description',
+        )
+
+    log.debug('Writing new description file %s' % path)
+
+    tmp = '%s.%d.tmp' % (path, os.getpid())
+    f = file(tmp, 'w')
+    try:
+        print >>f, description
+    finally:
+        f.close()
+    f.flush()
+    os.fsync(f.fileno())
+    os.rename(tmp, path)
+
+def _generate_user_description_entry(name, repositories, description):
+
+    log = logging.getLogger('gitosis.gitweb._generate_user_description_entry')
+    remove = repositories + os.sep
+    userpos = -1
+
+    # Deal with wildcards in the path
+    if -1 != name.find('$user'):
+        namesplit = name.split(os.sep)
+        # Work out which of the path components matches '$user' so
+        # we can use it later
+        for i in range(len(namesplit)):
+            if namesplit[i] == '$user':
+                userpos = i
+                break
+        name = name.replace('$user', '*')
+
+    if -1 != name.find('*'):
+        for entry in glob.glob(os.path.join(repositories, name)):
+            relative = entry.replace(remove, '', 1)
+            # Now, if we have a userpos we can use that to work
+            # out who owns this repository
+            if userpos != -1:
+                namesplit = relative.split(os.sep)
+                owner = namesplit[userpos]
+                try:
+                    # Let's see if we can get a full name rather
+                    # than just username here
+                    pwnam = pwd.getpwnam(owner)
+                    owner = pwnam[4]
+                except:
+                    pass
+            else:
+                owner = ""
+
+            new_description = description.replace('$username', owner)
+            log.debug('Found entry %s: %s' % (entry, new_description))
+            _write_description(repositories, entry, new_description)
 
 def set_descriptions(config):
     """
@@ -141,6 +249,13 @@ def set_descriptions(config):
 
         name, = l
 
+        log.debug('looking at name %s' % name)
+
+        # Special handling for $user and wildcard repositories
+        if -1 != name.find('$user') or -1 != name.find('*'):
+            _generate_user_description_entry(name, repositories, description)
+            continue
+
         if not os.path.exists(os.path.join(repositories, name)):
             namedotgit = '%s.git' % name
             if os.path.exists(os.path.join(repositories, namedotgit)):
@@ -151,15 +266,4 @@ def set_descriptions(config):
                     % dict(name=name, repositories=repositories))
                 continue
 
-        path = os.path.join(
-            repositories,
-            name,
-            'description',
-            )
-        tmp = '%s.%d.tmp' % (path, os.getpid())
-        f = file(tmp, 'w')
-        try:
-            print >>f, description
-        finally:
-            f.close()
-        os.rename(tmp, path)
+        _write_description(repositories, name, description)